@appshoteditor/shot-dsl 0.4.0 → 0.5.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.
- package/README.md +105 -23
- package/package.json +5 -2
- package/src/compose.ts +1057 -134
- package/src/decor.ts +137 -0
- package/src/index.ts +3 -0
- package/src/palette.ts +186 -0
- package/src/typography.ts +200 -0
- package/src/validate.ts +46 -2
- package/src/variants.ts +140 -43
package/src/decor.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decoration geometry for the composer (shot-dsl 0.5.0): panorama motifs (one Fabric `Path` that
|
|
3
|
+
* runs across a whole span, offset per screen like the span background) and the rectangle tests the
|
|
4
|
+
* callout / mascot placement uses. Pure math + plain JSON; no DSL layers are validated here.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface Rect {
|
|
8
|
+
left: number;
|
|
9
|
+
top: number;
|
|
10
|
+
right: number;
|
|
11
|
+
bottom: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const rectOf = (cx: number, cy: number, w: number, h: number): Rect => ({ left: cx - w / 2, top: cy - h / 2, right: cx + w / 2, bottom: cy + h / 2 });
|
|
15
|
+
|
|
16
|
+
export const overlaps = (a: Rect, b: Rect, pad = 0): boolean =>
|
|
17
|
+
a.left < b.right + pad && b.left < a.right + pad && a.top < b.bottom + pad && b.top < a.bottom + pad;
|
|
18
|
+
|
|
19
|
+
export const insideRect = (inner: Rect, outer: Rect, eps = 1e-6): boolean =>
|
|
20
|
+
inner.left >= outer.left - eps && inner.right <= outer.right + eps && inner.top >= outer.top - eps && inner.bottom <= outer.bottom + eps;
|
|
21
|
+
|
|
22
|
+
const area = (r: Rect) => Math.max(0, r.right - r.left) * Math.max(0, r.bottom - r.top);
|
|
23
|
+
const intersect = (a: Rect, b: Rect): Rect => ({ left: Math.max(a.left, b.left), top: Math.max(a.top, b.top), right: Math.min(a.right, b.right), bottom: Math.min(a.bottom, b.bottom) });
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Share (0–1) of the focus band `focus` that `card` hides, NOT counting the part of the band the card
|
|
27
|
+
* magnifies itself (`source`): (card ∩ focus − card ∩ focus ∩ source) ÷ (focus − focus ∩ source).
|
|
28
|
+
*/
|
|
29
|
+
export function focusCoverage(card: Rect, focus: Rect, source: Rect): number {
|
|
30
|
+
const fs = intersect(focus, source);
|
|
31
|
+
const rest = area(focus) - area(fs);
|
|
32
|
+
if (rest <= 1e-9) return 0;
|
|
33
|
+
const cf = intersect(card, focus);
|
|
34
|
+
return Math.max(0, area(cf) - area(intersect(cf, source))) / rest;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type PathCommand = [string, ...number[]];
|
|
38
|
+
|
|
39
|
+
export type Motif = 'orbs' | 'none' | 'honeycomb' | 'wave';
|
|
40
|
+
export const MOTIFS: readonly Motif[] = ['orbs', 'none', 'honeycomb', 'wave'];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A motif across a span `spanWidth` × H, as SUBPATHS (each a list of path commands) in SPAN
|
|
44
|
+
* coordinates, deterministic.
|
|
45
|
+
* - `honeycomb`: hexagon outlines along a gentle sine band that crosses every seam.
|
|
46
|
+
* - `wave`: three flowing parallel curves across the whole span.
|
|
47
|
+
*/
|
|
48
|
+
export function motifSubpaths(motif: Motif, spanWidth: number, W: number, H: number): PathCommand[][] {
|
|
49
|
+
const subpaths: PathCommand[][] = [];
|
|
50
|
+
if (motif === 'honeycomb') {
|
|
51
|
+
const r = 0.085 * W; // hexagon circumradius
|
|
52
|
+
const dx = Math.sqrt(3) * r;
|
|
53
|
+
const dy = 1.5 * r;
|
|
54
|
+
const centre = (x: number) => H * 0.62 + H * 0.12 * Math.sin((2 * Math.PI * x) / (1.6 * W) + 0.6);
|
|
55
|
+
for (let row = -6; row <= 6; row++) {
|
|
56
|
+
const y = H * 0.62 + row * dy;
|
|
57
|
+
for (let col = -1; col * dx <= spanWidth + dx; col++) {
|
|
58
|
+
const x = col * dx + (row % 2 ? dx / 2 : 0);
|
|
59
|
+
// Keep a band ~2.6 cells tall around the sine centre line, thinning at its edges.
|
|
60
|
+
const d = Math.abs(y - centre(x)) / dy;
|
|
61
|
+
if (d > 1.3) continue;
|
|
62
|
+
const rr = d > 0.8 ? r * 0.62 : r * 0.9;
|
|
63
|
+
const hex: PathCommand[] = [];
|
|
64
|
+
for (let k = 0; k < 6; k++) {
|
|
65
|
+
const a = (Math.PI / 3) * k + Math.PI / 6;
|
|
66
|
+
hex.push([k === 0 ? 'M' : 'L', x + rr * Math.cos(a), y + rr * Math.sin(a)]);
|
|
67
|
+
}
|
|
68
|
+
hex.push(['Z']);
|
|
69
|
+
subpaths.push(hex);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} else if (motif === 'wave') {
|
|
73
|
+
const steps = Math.max(24, Math.ceil(spanWidth / (0.04 * W)));
|
|
74
|
+
for (const [k, base] of [0.5, 0.58, 0.66].entries()) {
|
|
75
|
+
const line: PathCommand[] = [];
|
|
76
|
+
for (let i = 0; i <= steps; i++) {
|
|
77
|
+
const x = (i / steps) * spanWidth;
|
|
78
|
+
const y = H * base + H * 0.06 * Math.sin((2 * Math.PI * x) / (1.4 * W) + k * 0.7);
|
|
79
|
+
line.push([i === 0 ? 'M' : 'L', x, y]);
|
|
80
|
+
}
|
|
81
|
+
subpaths.push(line);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return subpaths;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Path coordinate precision (canvas units): 0.1 keeps seams exact to well under a device pixel. */
|
|
88
|
+
const MOTIF_PRECISION = 10;
|
|
89
|
+
const round1 = (v: number) => Math.round(v * MOTIF_PRECISION) / MOTIF_PRECISION;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The part of a span-wide motif that span screen `k` shows, in THAT screen's coordinates: only the
|
|
93
|
+
* subpaths (hexagons) touching [−pad, W + pad], and for open polylines only the segments that do
|
|
94
|
+
* (plus the segment crossing each window edge, so strokes run continuously over the seam).
|
|
95
|
+
* Coordinates are rounded to 0.1 unit. Both neighbours emit the geometry around their shared seam
|
|
96
|
+
* from the same span-space points (rounded after the same −k·W shift of an integer-width offset),
|
|
97
|
+
* so the halves meet exactly. Returns the commands and their bounding box (Fabric positions a Path
|
|
98
|
+
* by its bbox centre), or null when nothing is visible.
|
|
99
|
+
*/
|
|
100
|
+
export function motifPathForScreen(subpaths: PathCommand[][], k: number, W: number, pad: number): { path: PathCommand[]; bbox: Rect } | null {
|
|
101
|
+
const lo = k * W - pad;
|
|
102
|
+
const hi = (k + 1) * W + pad;
|
|
103
|
+
const out: PathCommand[] = [];
|
|
104
|
+
for (const sub of subpaths) {
|
|
105
|
+
const pts = sub.filter((c) => c.length >= 3) as Array<[string, number, number]>;
|
|
106
|
+
if (!pts.length) continue;
|
|
107
|
+
const closed = sub.some((c) => c[0] === 'Z');
|
|
108
|
+
if (closed) {
|
|
109
|
+
const xs = pts.map((c) => c[1]);
|
|
110
|
+
if (Math.max(...xs) < lo || Math.min(...xs) > hi) continue;
|
|
111
|
+
sub.forEach((c) => out.push(c.length >= 3 ? [c[0], round1((c[1] as number) - k * W), round1(c[2] as number)] : [c[0]]));
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
// Open polyline: keep point i when it or a neighbour lies inside the window.
|
|
115
|
+
const inside = pts.map((c) => c[1] >= lo && c[1] <= hi);
|
|
116
|
+
let pen = false;
|
|
117
|
+
pts.forEach((c, i) => {
|
|
118
|
+
const keep = inside[i] || (i > 0 && inside[i - 1]) || (i < pts.length - 1 && inside[i + 1]);
|
|
119
|
+
if (!keep) {
|
|
120
|
+
pen = false;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
out.push([pen ? 'L' : 'M', round1(c[1] - k * W), round1(c[2])]);
|
|
124
|
+
pen = true;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const xs: number[] = [];
|
|
128
|
+
const ys: number[] = [];
|
|
129
|
+
for (const c of out) {
|
|
130
|
+
if (c.length >= 3) {
|
|
131
|
+
xs.push(c[1] as number);
|
|
132
|
+
ys.push(c[2] as number);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (!xs.length) return null;
|
|
136
|
+
return { path: out, bbox: { left: Math.min(...xs), top: Math.min(...ys), right: Math.max(...xs), bottom: Math.max(...ys) } };
|
|
137
|
+
}
|
package/src/index.ts
CHANGED
package/src/palette.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brand palette system (shot-dsl 0.5.0): tonal backgrounds from the app's brand colours and ONE
|
|
3
|
+
* set-wide text colour that passes WCAG AA (≥ 4.5:1) on every style-derived background.
|
|
4
|
+
*
|
|
5
|
+
* - `tonal` (new): `colors: [base, accent?]`, `tone: light | vivid | deep`. Each screen gets a
|
|
6
|
+
* light↔deep step of the base hue as a subtle vertical gradient (`light`: pale tints; `vivid`:
|
|
7
|
+
* around the brand colour; `deep`: richer, darker, warmer steps — still a colour, not a
|
|
8
|
+
* near-black); the text colour is a deep (or pale) tint of the same hue, never a grey.
|
|
9
|
+
* - `family` / `sequence` (0.4.0): unchanged colours, but now also ONE text colour per set.
|
|
10
|
+
*
|
|
11
|
+
* Contrast is enforced by generation: `harmonize` evaluates both text candidates (dark / light),
|
|
12
|
+
* picks the one that needs the smaller tone adjustment, and moves every style-derived background
|
|
13
|
+
* stop away from the text colour (lighter under dark text, deeper under light text) in small
|
|
14
|
+
* steps until the WORST sampled point of every background passes.
|
|
15
|
+
*/
|
|
16
|
+
import type { BackgroundJSON, ColorStop } from './types';
|
|
17
|
+
import { contrastRatio, isHexColor, mixHex, parseHex, toHex, DARK_TEXT, LIGHT_TEXT } from './color';
|
|
18
|
+
|
|
19
|
+
export const MIN_CONTRAST = 4.5;
|
|
20
|
+
/** Generation aims a little above AA so rounding never lands a sample at 4.49. */
|
|
21
|
+
const TARGET_CONTRAST = 4.6;
|
|
22
|
+
|
|
23
|
+
export type PaletteTone = 'light' | 'vivid' | 'deep';
|
|
24
|
+
|
|
25
|
+
export interface HSL {
|
|
26
|
+
h: number;
|
|
27
|
+
s: number;
|
|
28
|
+
l: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function hexToHsl(hex: string): HSL {
|
|
32
|
+
const c = parseHex(hex) ?? { r: 31, g: 41, b: 55 };
|
|
33
|
+
const r = c.r / 255;
|
|
34
|
+
const g = c.g / 255;
|
|
35
|
+
const b = c.b / 255;
|
|
36
|
+
const max = Math.max(r, g, b);
|
|
37
|
+
const min = Math.min(r, g, b);
|
|
38
|
+
const l = (max + min) / 2;
|
|
39
|
+
if (max === min) return { h: 0, s: 0, l };
|
|
40
|
+
const d = max - min;
|
|
41
|
+
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
42
|
+
let h: number;
|
|
43
|
+
if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
|
|
44
|
+
else if (max === g) h = (b - r) / d + 2;
|
|
45
|
+
else h = (r - g) / d + 4;
|
|
46
|
+
return { h: h * 60, s, l };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function hslToHex({ h, s, l }: HSL): string {
|
|
50
|
+
const hh = (((h % 360) + 360) % 360) / 360;
|
|
51
|
+
const ss = Math.min(1, Math.max(0, s));
|
|
52
|
+
const ll = Math.min(1, Math.max(0, l));
|
|
53
|
+
if (ss === 0) return toHex({ r: ll * 255, g: ll * 255, b: ll * 255 });
|
|
54
|
+
const q = ll < 0.5 ? ll * (1 + ss) : ll + ss - ll * ss;
|
|
55
|
+
const p = 2 * ll - q;
|
|
56
|
+
const hue = (t: number) => {
|
|
57
|
+
let x = t;
|
|
58
|
+
if (x < 0) x += 1;
|
|
59
|
+
if (x > 1) x -= 1;
|
|
60
|
+
if (x < 1 / 6) return p + (q - p) * 6 * x;
|
|
61
|
+
if (x < 1 / 2) return q;
|
|
62
|
+
if (x < 2 / 3) return p + (q - p) * (2 / 3 - x) * 6;
|
|
63
|
+
return p;
|
|
64
|
+
};
|
|
65
|
+
return toHex({ r: hue(hh + 1 / 3) * 255, g: hue(hh) * 255, b: hue(hh - 1 / 3) * 255 });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Lightness steps (and per-step hue drift) of each tone; screen i uses step i % 4, the hero step 0. */
|
|
69
|
+
const TONE_STEPS: Record<PaletteTone, { l: number[]; sat: number; spread: number }> = {
|
|
70
|
+
light: { l: [0.9, 0.93, 0.87, 0.95], sat: 0.85, spread: 0.035 },
|
|
71
|
+
vivid: { l: [0, 0.09, -0.06, 0.15], sat: 1, spread: 0.07 },
|
|
72
|
+
// Deep = richer, darker and warmer than the brand colour, still a colour (not a near-black).
|
|
73
|
+
deep: { l: [0.46, 0.52, 0.42, 0.49], sat: 1, spread: 0.08 }
|
|
74
|
+
};
|
|
75
|
+
const HUE_DRIFT: Record<PaletteTone, number[]> = { light: [0, -5, 4, -8], vivid: [0, -5, 4, -8], deep: [-8, -3, -12, -6] };
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The tonal background of step `step` for `base` (before any contrast shift): a vertical gradient
|
|
79
|
+
* (editor angle 180°: offset 0 = BOTTOM) from a slightly deeper tone at the bottom to a lighter
|
|
80
|
+
* one at the top.
|
|
81
|
+
*/
|
|
82
|
+
export function tonalBackground(base: string, tone: PaletteTone, step: number): BackgroundJSON {
|
|
83
|
+
const hsl = hexToHsl(base);
|
|
84
|
+
const spec = TONE_STEPS[tone];
|
|
85
|
+
const k = ((step % 4) + 4) % 4;
|
|
86
|
+
const l = tone === 'vivid' ? Math.min(0.72, Math.max(0.34, hsl.l + spec.l[k])) : spec.l[k];
|
|
87
|
+
const s = Math.min(1, hsl.s * spec.sat);
|
|
88
|
+
const h = hsl.h + HUE_DRIFT[tone][k];
|
|
89
|
+
const stops: ColorStop[] = [
|
|
90
|
+
{ offset: 0, color: hslToHex({ h: h + 3, s, l: l - spec.spread }) },
|
|
91
|
+
{ offset: 1, color: hslToHex({ h: h - 3, s: s * 0.96, l: l + spec.spread }) }
|
|
92
|
+
];
|
|
93
|
+
return { type: 'gradient', gradient: { type: 'linear', colorStops: stops, angle: 180 } };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Text candidates for a palette: a deep tint of the base hue and a pale one (tonal), else neutral. */
|
|
97
|
+
export function textCandidates(base: string | null): { dark: string; light: string } {
|
|
98
|
+
if (!base) return { dark: DARK_TEXT, light: LIGHT_TEXT };
|
|
99
|
+
const { h, s } = hexToHsl(base);
|
|
100
|
+
return {
|
|
101
|
+
dark: hslToHex({ h, s: Math.min(0.75, s * 0.8 + 0.1), l: 0.11 }),
|
|
102
|
+
light: hslToHex({ h, s: Math.min(1, s), l: 0.975 })
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Move a background's stops `t` (0–1) of the way toward `target` (white or black). */
|
|
107
|
+
export function shiftBackground(bg: BackgroundJSON, target: string, t: number): BackgroundJSON {
|
|
108
|
+
if (t <= 0) return bg;
|
|
109
|
+
if (bg.type !== 'gradient' || !bg.gradient) {
|
|
110
|
+
return { ...bg, color: isHexColor(bg.color) ? mixHex(bg.color, target, t) : bg.color };
|
|
111
|
+
}
|
|
112
|
+
const move = (c: string) => (isHexColor(c) ? mixHex(c, target, t) : c);
|
|
113
|
+
return {
|
|
114
|
+
...bg,
|
|
115
|
+
gradient: {
|
|
116
|
+
...bg.gradient,
|
|
117
|
+
colorStops: (bg.gradient.colorStops ?? []).map((s) => ({ ...s, color: move(s.color) })),
|
|
118
|
+
...(bg.gradient.colors ? { colors: bg.gradient.colors.map(move) } : {})
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Worst WCAG contrast of `text` over sampled colours. */
|
|
124
|
+
export function worstContrast(text: string, samples: string[]): number {
|
|
125
|
+
return samples.length ? Math.min(...samples.map((c) => contrastRatio(text, c))) : 21;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface Harmonized {
|
|
129
|
+
text: string;
|
|
130
|
+
/** Fraction the style-derived backgrounds were moved away from the text colour (0 = untouched). */
|
|
131
|
+
shift: number;
|
|
132
|
+
/** Worst contrast over every shiftable sample after the shift. */
|
|
133
|
+
worst: number;
|
|
134
|
+
/** Worst contrast over the FIXED samples (explicit backgrounds, never recoloured); 21 when none. */
|
|
135
|
+
worstFixed: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Choose the set's text colour and the tone shift that makes it pass everywhere.
|
|
140
|
+
* - `samplesAt(target, shift)`: every sampled colour of the style-derived backgrounds (palette
|
|
141
|
+
* screens, panorama spans), moved toward `target` by `shift`.
|
|
142
|
+
* - `fixed`: sampled colours of explicit backgrounds — they count toward the choice but are never
|
|
143
|
+
* recoloured.
|
|
144
|
+
* Preference: the candidate that reaches AA on the shiftable samples with the least re-toning; the
|
|
145
|
+
* other one instead only if IT also passes on the fixed samples and costs ≤ 0.15 more shift. Failing
|
|
146
|
+
* explicit screens then get their own colour from the caller. `preferLight` breaks exact ties (the
|
|
147
|
+
* 0.4.0 white default for explicit-only sets).
|
|
148
|
+
*/
|
|
149
|
+
export function harmonize(
|
|
150
|
+
candidates: { dark: string; light: string },
|
|
151
|
+
samplesAt: (target: string, shift: number) => string[],
|
|
152
|
+
fixed: string[] = [],
|
|
153
|
+
preferLight = false
|
|
154
|
+
): Harmonized {
|
|
155
|
+
const solve = (text: string, target: string): Harmonized => {
|
|
156
|
+
let shift = 0;
|
|
157
|
+
let worst = worstContrast(text, samplesAt(target, 0));
|
|
158
|
+
for (let i = 0; i < 40 && worst < TARGET_CONTRAST; i++) {
|
|
159
|
+
shift = Math.min(1, shift + 0.025);
|
|
160
|
+
worst = worstContrast(text, samplesAt(target, shift));
|
|
161
|
+
if (shift >= 1) break;
|
|
162
|
+
}
|
|
163
|
+
return { text, shift, worst, worstFixed: worstContrast(text, fixed) };
|
|
164
|
+
};
|
|
165
|
+
const dark = solve(candidates.dark, '#FFFFFF');
|
|
166
|
+
const light = solve(candidates.light, '#000000');
|
|
167
|
+
const passes = (h: Harmonized) => h.worst >= MIN_CONTRAST;
|
|
168
|
+
const passesAll = (h: Harmonized) => passes(h) && h.worstFixed >= MIN_CONTRAST;
|
|
169
|
+
const pick = (a: Harmonized, b: Harmonized) => {
|
|
170
|
+
if (a.shift !== b.shift) return a.shift < b.shift ? a : b;
|
|
171
|
+
if (preferLight) return b;
|
|
172
|
+
return Math.min(a.worst, a.worstFixed) >= Math.min(b.worst, b.worstFixed) ? a : b;
|
|
173
|
+
};
|
|
174
|
+
// Primary: the candidate that makes the palette pass with the least re-toning.
|
|
175
|
+
const primary = passes(dark) && passes(light) ? pick(dark, light) : passes(dark) ? dark : passes(light) ? light : dark.worst >= light.worst ? dark : light;
|
|
176
|
+
if (passesAll(primary)) return primary;
|
|
177
|
+
// The other candidate may also pass on the explicit backgrounds — worth it only if that costs
|
|
178
|
+
// little extra re-toning (a light palette is never turned dark to suit one dark screen; that
|
|
179
|
+
// screen gets its own colour instead).
|
|
180
|
+
const other = primary === dark ? light : dark;
|
|
181
|
+
if (passesAll(other) && other.shift <= primary.shift + MAX_EXTRA_SHIFT_FOR_FIXED) return other;
|
|
182
|
+
return primary;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** How much more palette re-toning is acceptable to get ONE colour that also passes on explicit backgrounds. */
|
|
186
|
+
const MAX_EXTRA_SHIFT_FOR_FIXED = 0.15;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line breaking by meaning (shot-dsl 0.5.0). The composer never lets Fabric's greedy wrap decide a
|
|
3
|
+
* headline's shape:
|
|
4
|
+
* - an explicit `\n` in the copy is ALWAYS a line break — lines the author broke are never merged;
|
|
5
|
+
* - a line (explicit or not) that doesn't fit the box is broken into the SAME number of lines the
|
|
6
|
+
* greedy wrap would use, but balanced — minimal raggedness, no single short word stranded on the
|
|
7
|
+
* last line, breaks preferred after punctuation and never right after a short function word.
|
|
8
|
+
* The chosen breaks are emitted as `\n` in the Textbox text, so the editor shows exactly these lines
|
|
9
|
+
* (each fits the box by the conservative width model, so Fabric never re-wraps them).
|
|
10
|
+
*
|
|
11
|
+
* CJK (and other scripts written without spaces) may break between any two of their characters:
|
|
12
|
+
* such characters are tokens of their own, joined without a space.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Width of one line (canvas units) for the font model in use. */
|
|
16
|
+
export type LineWidth = (line: string) => number;
|
|
17
|
+
|
|
18
|
+
/** Words that read badly at the END of a line ("Hear the / word"). */
|
|
19
|
+
const WEAK_ENDINGS = new Set([
|
|
20
|
+
'a',
|
|
21
|
+
'an',
|
|
22
|
+
'the',
|
|
23
|
+
'to',
|
|
24
|
+
'of',
|
|
25
|
+
'and',
|
|
26
|
+
'or',
|
|
27
|
+
'for',
|
|
28
|
+
'in',
|
|
29
|
+
'on',
|
|
30
|
+
'at',
|
|
31
|
+
'by',
|
|
32
|
+
'with',
|
|
33
|
+
'your',
|
|
34
|
+
'my',
|
|
35
|
+
'our',
|
|
36
|
+
'their',
|
|
37
|
+
'is',
|
|
38
|
+
'it’s',
|
|
39
|
+
"it's",
|
|
40
|
+
'&'
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const PUNCT_END = /[.,:;!?—–)。,、!?]$/;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Beyond this many tokens a line is broken greedily (linear) instead of balanced (the balancing DP is
|
|
47
|
+
* O(tokens² · lines)). Real headlines are ≤ ~12 words; this only keeps absurd copy fast — it fails
|
|
48
|
+
* the copy-length rules anyway.
|
|
49
|
+
*/
|
|
50
|
+
export const MAX_BALANCED_TOKENS = 40;
|
|
51
|
+
|
|
52
|
+
/** Ideographs, kana, Hangul, fullwidth forms: break anywhere between them; about 1 em wide. */
|
|
53
|
+
export function isWideChar(ch: string): boolean {
|
|
54
|
+
const c = ch.codePointAt(0) ?? 0;
|
|
55
|
+
return (
|
|
56
|
+
(c >= 0x1100 && c <= 0x115f) || // Hangul Jamo
|
|
57
|
+
(c >= 0x2e80 && c <= 0x303e) || // CJK radicals, punctuation
|
|
58
|
+
(c >= 0x3041 && c <= 0x33ff) || // kana, CJK symbols
|
|
59
|
+
(c >= 0x3400 && c <= 0x4dbf) || // CJK ext A
|
|
60
|
+
(c >= 0x4e00 && c <= 0x9fff) || // CJK unified
|
|
61
|
+
(c >= 0xa960 && c <= 0xa97f) ||
|
|
62
|
+
(c >= 0xac00 && c <= 0xd7a3) || // Hangul syllables
|
|
63
|
+
(c >= 0xf900 && c <= 0xfaff) ||
|
|
64
|
+
(c >= 0xfe30 && c <= 0xfe4f) ||
|
|
65
|
+
(c >= 0xff00 && c <= 0xff60) || // fullwidth forms
|
|
66
|
+
(c >= 0xffe0 && c <= 0xffe6) ||
|
|
67
|
+
(c >= 0x20000 && c <= 0x3fffd)
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface Token {
|
|
72
|
+
text: string;
|
|
73
|
+
/** A space precedes this token when it follows another on the same line. */
|
|
74
|
+
space: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Words split at spaces; runs of wide characters split per character (no space between them). */
|
|
78
|
+
function tokenize(line: string): Token[] {
|
|
79
|
+
const tokens: Token[] = [];
|
|
80
|
+
for (const word of line.split(/\s+/).filter(Boolean)) {
|
|
81
|
+
let buf = '';
|
|
82
|
+
let first = true;
|
|
83
|
+
const flush = () => {
|
|
84
|
+
if (!buf) return;
|
|
85
|
+
tokens.push({ text: buf, space: first });
|
|
86
|
+
first = false;
|
|
87
|
+
buf = '';
|
|
88
|
+
};
|
|
89
|
+
for (const ch of word) {
|
|
90
|
+
if (isWideChar(ch)) {
|
|
91
|
+
flush();
|
|
92
|
+
tokens.push({ text: ch, space: first });
|
|
93
|
+
first = false;
|
|
94
|
+
} else buf += ch;
|
|
95
|
+
}
|
|
96
|
+
flush();
|
|
97
|
+
}
|
|
98
|
+
return tokens;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const join = (tokens: Token[], i: number, j: number) => tokens.slice(i, j).reduce((s, t, k) => s + (k > 0 && t.space ? ' ' : '') + t.text, '');
|
|
102
|
+
|
|
103
|
+
/** Greedy wrap at token boundaries — the line COUNT to balance to (and the fallback breaking). */
|
|
104
|
+
function greedy(tokens: Token[], maxWidth: number, width: LineWidth): Array<[number, number]> {
|
|
105
|
+
const lines: Array<[number, number]> = [];
|
|
106
|
+
let start = 0;
|
|
107
|
+
for (let j = 1; j < tokens.length; j++) {
|
|
108
|
+
if (width(join(tokens, start, j + 1)) > maxWidth) {
|
|
109
|
+
lines.push([start, j]);
|
|
110
|
+
start = j;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (tokens.length) lines.push([start, tokens.length]);
|
|
114
|
+
return lines;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Balanced break of one line into its greedy line count (greedy past MAX_BALANCED_TOKENS). A single
|
|
119
|
+
* token wider than the box stays on its own line (the caller reports it — see `overlongWords`).
|
|
120
|
+
*/
|
|
121
|
+
function balanceLine(line: string, maxWidth: number, width: LineWidth): string[] {
|
|
122
|
+
const tokens = tokenize(line);
|
|
123
|
+
if (tokens.length === 0) return [''];
|
|
124
|
+
if (width(join(tokens, 0, tokens.length)) <= maxWidth) return [join(tokens, 0, tokens.length)];
|
|
125
|
+
const g = greedy(tokens, maxWidth, width);
|
|
126
|
+
const n = g.length;
|
|
127
|
+
const T = tokens.length;
|
|
128
|
+
if (n === 1 || T > MAX_BALANCED_TOKENS || tokens.some((t) => width(t.text) > maxWidth)) return g.map(([i, j]) => join(tokens, i, j));
|
|
129
|
+
|
|
130
|
+
const cost = (i: number, j: number, last: boolean): number => {
|
|
131
|
+
const w = width(join(tokens, i, j));
|
|
132
|
+
if (w > maxWidth) return Infinity;
|
|
133
|
+
const slack = (maxWidth - w) / maxWidth;
|
|
134
|
+
let c = slack * slack;
|
|
135
|
+
const lastWord = tokens[j - 1].text;
|
|
136
|
+
const words = j - i;
|
|
137
|
+
if (!last) {
|
|
138
|
+
if (PUNCT_END.test(lastWord)) c -= 0.12;
|
|
139
|
+
if (WEAK_ENDINGS.has(lastWord.toLowerCase())) c += 0.35;
|
|
140
|
+
// A lone word on an inner line ("See / their progress") reads as stranded too.
|
|
141
|
+
if (words === 1 && T > 2) c += 0.6;
|
|
142
|
+
} else if (words === 1 && T > 1) {
|
|
143
|
+
// A single word alone on the last line (an orphan) — heavily penalised.
|
|
144
|
+
c += 1.2;
|
|
145
|
+
}
|
|
146
|
+
return c;
|
|
147
|
+
};
|
|
148
|
+
// DP over break positions: best[k][j] = min cost of tokens[0..j) in k lines.
|
|
149
|
+
const best: number[][] = Array.from({ length: n + 1 }, () => new Array(T + 1).fill(Infinity));
|
|
150
|
+
const from: number[][] = Array.from({ length: n + 1 }, () => new Array(T + 1).fill(-1));
|
|
151
|
+
best[0][0] = 0;
|
|
152
|
+
for (let k = 1; k <= n; k++) {
|
|
153
|
+
for (let j = k; j <= T; j++) {
|
|
154
|
+
for (let i = k - 1; i < j; i++) {
|
|
155
|
+
if (best[k - 1][i] === Infinity) continue;
|
|
156
|
+
const c = best[k - 1][i] + cost(i, j, k === n && j === T);
|
|
157
|
+
if (c < best[k][j]) {
|
|
158
|
+
best[k][j] = c;
|
|
159
|
+
from[k][j] = i;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (best[n][T] === Infinity) return g.map(([i, j]) => join(tokens, i, j));
|
|
165
|
+
const lines: string[] = [];
|
|
166
|
+
for (let k = n, j = T; k > 0; k--) {
|
|
167
|
+
const i = from[k][j];
|
|
168
|
+
lines.unshift(join(tokens, i, j));
|
|
169
|
+
j = i;
|
|
170
|
+
}
|
|
171
|
+
return lines;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The lines `text` is set in: every explicit `\n` line is kept as written, and only a line that
|
|
176
|
+
* doesn't fit the box is broken (balanced) — never merged with its neighbours.
|
|
177
|
+
*/
|
|
178
|
+
export function breakLines(text: string, maxWidth: number, width: LineWidth): string[] {
|
|
179
|
+
return text
|
|
180
|
+
.replace(/\r/g, '')
|
|
181
|
+
.split('\n')
|
|
182
|
+
.flatMap((line) => balanceLine(line.trim().replace(/\s+/g, ' '), maxWidth, width));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Tokens (words, or single wide characters) wider than the box. Fabric's Textbox never breaks inside
|
|
187
|
+
* a word — it widens the box to the longest word, which would overflow the side margin — so the
|
|
188
|
+
* composer rejects such copy with a clear error.
|
|
189
|
+
*/
|
|
190
|
+
export function overlongWords(text: string, maxWidth: number, width: LineWidth): string[] {
|
|
191
|
+
return tokenize(text.replace(/\n/g, ' ')).filter((t) => width(t.text) > maxWidth).map((t) => t.text);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** True when the LAST line of `lines` is one short word while the block has more than one line. */
|
|
195
|
+
export function hasOrphan(lines: string[]): boolean {
|
|
196
|
+
if (lines.length < 2) return false;
|
|
197
|
+
const last = lines[lines.length - 1].trim().split(/\s+/).filter(Boolean);
|
|
198
|
+
const prev = lines[lines.length - 2].trim().split(/\s+/).filter(Boolean);
|
|
199
|
+
return last.length === 1 && [...last[0]].length > 0 && !isWideChar([...last[0]][0]) && prev.length > 1;
|
|
200
|
+
}
|
package/src/validate.ts
CHANGED
|
@@ -199,12 +199,38 @@ function checkImageRefs(value: unknown, at: string, errors: string[], depth = 0)
|
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
const DEVICE_SHADOW_KEYS = new Set(['color', 'blur', 'offsetX', 'offsetY']);
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Problems with a device layer's `fabricData.deviceShadow` (shot-dsl 0.5.0): `{ color, blur, offsetX,
|
|
206
|
+
* offsetY }` in canvas units — a colour string (≤ 64 chars) and finite numbers, blur in
|
|
207
|
+
* [0, canvasWidth] and offsets within ±canvasWidth (the composer emits ~7.5% / 3% of W).
|
|
208
|
+
*/
|
|
209
|
+
export function validateDeviceShadow(value: unknown, canvasWidth: number, at = 'deviceShadow'): string[] {
|
|
210
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return [`${at} must be an object`];
|
|
211
|
+
const errors: string[] = [];
|
|
212
|
+
const v = value as Record<string, unknown>;
|
|
213
|
+
for (const key of Object.keys(v)) if (!DEVICE_SHADOW_KEYS.has(key)) errors.push(`${at}.${key} is not allowed`);
|
|
214
|
+
if (typeof v.color !== 'string' || !v.color || v.color.length > 64) errors.push(`${at}.color must be a colour string (≤ 64 chars)`);
|
|
215
|
+
const limit = canvasWidth > 0 && Number.isFinite(canvasWidth) ? canvasWidth : 4096;
|
|
216
|
+
const num = (key: string, min: number) => {
|
|
217
|
+
const n = v[key];
|
|
218
|
+
if (n === undefined && key !== 'blur') return;
|
|
219
|
+
if (typeof n !== 'number' || !Number.isFinite(n) || n < min || n > limit) errors.push(`${at}.${key} must be a number from ${min} to ${limit}`);
|
|
220
|
+
};
|
|
221
|
+
num('blur', 0);
|
|
222
|
+
num('offsetX', -limit);
|
|
223
|
+
num('offsetY', -limit);
|
|
224
|
+
return errors;
|
|
225
|
+
}
|
|
226
|
+
|
|
202
227
|
/** Per-layer fabricData rules for handoffs (see validateTemplate). */
|
|
203
228
|
function validateLayerFabricData(
|
|
204
229
|
layer: LayerJSON,
|
|
205
230
|
fd: Record<string, unknown>,
|
|
206
231
|
at: string,
|
|
207
|
-
frameIds: Set<string
|
|
232
|
+
frameIds: Set<string>,
|
|
233
|
+
canvasWidth = 0
|
|
208
234
|
): string[] {
|
|
209
235
|
const errors: string[] = [];
|
|
210
236
|
const fdAt = `${at}.fabricData`;
|
|
@@ -251,6 +277,12 @@ function validateLayerFabricData(
|
|
|
251
277
|
if (fd.layerType === 'deviceFrame') errors.push(`${fdAt}.layerType "deviceFrame" is only allowed on device layers`);
|
|
252
278
|
}
|
|
253
279
|
|
|
280
|
+
// Device drop shadow (0.5.0).
|
|
281
|
+
if (fd.deviceShadow !== undefined) {
|
|
282
|
+
if (layer.type !== 'device') errors.push(`${fdAt}.deviceShadow is only allowed on device layers`);
|
|
283
|
+
errors.push(...validateDeviceShadow(fd.deviceShadow, canvasWidth, `${fdAt}.deviceShadow`));
|
|
284
|
+
}
|
|
285
|
+
|
|
254
286
|
// Device-owned screenshot (0.3.0+).
|
|
255
287
|
if (fd.screenshot !== undefined) {
|
|
256
288
|
if (layer.type !== 'device') errors.push(`${fdAt}.screenshot is only allowed on device layers`);
|
|
@@ -272,6 +304,18 @@ function validateLayerFabricData(
|
|
|
272
304
|
return errors;
|
|
273
305
|
}
|
|
274
306
|
|
|
307
|
+
/**
|
|
308
|
+
* The handoff API's size cap: `POST /api/handoffs` rejects a body over this many bytes (413). The
|
|
309
|
+
* body is `JSON.stringify({ template })`. Producers (the skill's `lint` / `publish`) check
|
|
310
|
+
* `handoffBytes` against it before any network call.
|
|
311
|
+
*/
|
|
312
|
+
export const MAX_HANDOFF_BYTES = 256 * 1024;
|
|
313
|
+
|
|
314
|
+
/** UTF-8 size of the handoff request body for `template` (what the server measures). */
|
|
315
|
+
export function handoffBytes(template: unknown): number {
|
|
316
|
+
return new TextEncoder().encode(JSON.stringify({ template })).length;
|
|
317
|
+
}
|
|
318
|
+
|
|
275
319
|
export interface ValidationResult {
|
|
276
320
|
valid: boolean;
|
|
277
321
|
errors: string[];
|
|
@@ -315,7 +359,7 @@ export function validateTemplate(data: unknown): ValidationResult {
|
|
|
315
359
|
|
|
316
360
|
const fd = layer.fabricData as Record<string, unknown> | null;
|
|
317
361
|
if (!fd || typeof fd !== 'object') return;
|
|
318
|
-
errors.push(...validateLayerFabricData(layer, fd, at, frameIds));
|
|
362
|
+
errors.push(...validateLayerFabricData(layer, fd, at, frameIds, s.canvasWidth ?? 0));
|
|
319
363
|
});
|
|
320
364
|
});
|
|
321
365
|
}
|