capture-flow 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +238 -2
- package/bin/capture-flow.mjs +12 -0
- package/package.json +55 -4
- package/src/build.ts +186 -0
- package/src/capture.ts +77 -0
- package/src/cli.ts +139 -0
- package/src/index.ts +11 -0
- package/src/manifest.ts +98 -0
- package/src/render.ts +75 -0
- package/src/verify.ts +90 -0
- package/vendor/wajha/LICENSE +21 -0
- package/vendor/wajha/NOTICE +21 -0
- package/vendor/wajha/engine/background.ts +55 -0
- package/vendor/wajha/engine/bidi.ts +589 -0
- package/vendor/wajha/engine/cache.ts +96 -0
- package/vendor/wajha/engine/color.ts +125 -0
- package/vendor/wajha/engine/devices/android.ts +53 -0
- package/vendor/wajha/engine/devices/browser.ts +170 -0
- package/vendor/wajha/engine/devices/finish.ts +154 -0
- package/vendor/wajha/engine/devices/geometry.ts +326 -0
- package/vendor/wajha/engine/devices/index.ts +143 -0
- package/vendor/wajha/engine/devices/ipad.ts +21 -0
- package/vendor/wajha/engine/devices/iphone.ts +55 -0
- package/vendor/wajha/engine/devices/macbook.ts +187 -0
- package/vendor/wajha/engine/devices/mock/apps/calendar.ts +145 -0
- package/vendor/wajha/engine/devices/mock/apps/chat.ts +213 -0
- package/vendor/wajha/engine/devices/mock/apps/finance.ts +164 -0
- package/vendor/wajha/engine/devices/mock/apps/fitness.ts +145 -0
- package/vendor/wajha/engine/devices/mock/apps/index.ts +14 -0
- package/vendor/wajha/engine/devices/mock/apps/meditation.ts +167 -0
- package/vendor/wajha/engine/devices/mock/apps/music.ts +152 -0
- package/vendor/wajha/engine/devices/mock/apps/shop.ts +116 -0
- package/vendor/wajha/engine/devices/mock/apps/social.ts +152 -0
- package/vendor/wajha/engine/devices/mock/apps/tasks.ts +131 -0
- package/vendor/wajha/engine/devices/mock/apps/travel.ts +121 -0
- package/vendor/wajha/engine/devices/mock/art.ts +557 -0
- package/vendor/wajha/engine/devices/mock/chrome.ts +337 -0
- package/vendor/wajha/engine/devices/mock/icons.ts +614 -0
- package/vendor/wajha/engine/devices/mock/kit.ts +213 -0
- package/vendor/wajha/engine/devices/mock/rng.ts +40 -0
- package/vendor/wajha/engine/devices/mock/theme.ts +133 -0
- package/vendor/wajha/engine/devices/mock/widgets.ts +231 -0
- package/vendor/wajha/engine/devices/paths.ts +113 -0
- package/vendor/wajha/engine/devices/screen.ts +75 -0
- package/vendor/wajha/engine/devices/shadow.ts +124 -0
- package/vendor/wajha/engine/devices/slab.ts +200 -0
- package/vendor/wajha/engine/devices/vision.ts +43 -0
- package/vendor/wajha/engine/devices/watch.ts +14 -0
- package/vendor/wajha/engine/geometry.ts +152 -0
- package/vendor/wajha/engine/images.ts +96 -0
- package/vendor/wajha/engine/index.ts +20 -0
- package/vendor/wajha/engine/linebreak.ts +226 -0
- package/vendor/wajha/engine/markup.ts +84 -0
- package/vendor/wajha/engine/mock.ts +158 -0
- package/vendor/wajha/engine/paint.ts +474 -0
- package/vendor/wajha/engine/patterns.ts +263 -0
- package/vendor/wajha/engine/render.ts +364 -0
- package/vendor/wajha/engine/shapes.ts +260 -0
- package/vendor/wajha/engine/testing/canvasNode.ts +121 -0
- package/vendor/wajha/engine/testing/fakeDevices.ts +85 -0
- package/vendor/wajha/engine/text.ts +694 -0
- package/vendor/wajha/engine/types.ts +190 -0
- package/vendor/wajha/export/paths.ts +197 -0
- package/vendor/wajha/export/png.ts +223 -0
- package/vendor/wajha/export/types.ts +60 -0
- package/vendor/wajha/model/adapt.ts +889 -0
- package/vendor/wajha/model/catalog.ts +369 -0
- package/vendor/wajha/model/clone.ts +58 -0
- package/vendor/wajha/model/colorMath.ts +128 -0
- package/vendor/wajha/model/defaults.ts +386 -0
- package/vendor/wajha/model/geometry.ts +95 -0
- package/vendor/wajha/model/ids.ts +13 -0
- package/vendor/wajha/model/instantiate.ts +280 -0
- package/vendor/wajha/model/mirror.ts +43 -0
- package/vendor/wajha/model/resolve.ts +87 -0
- package/vendor/wajha/model/rules.ts +249 -0
- package/vendor/wajha/model/stub.ts +4 -0
- package/vendor/wajha/model/types.ts +456 -0
- package/vendor/wajha/model/typography.ts +28 -0
- package/vendor/wajha/model/validate.ts +917 -0
- package/vendor/wajha/templates/builtin/aurora.ts +163 -0
- package/vendor/wajha/templates/builtin/bubblegum.ts +132 -0
- package/vendor/wajha/templates/builtin/citrusPop.ts +124 -0
- package/vendor/wajha/templates/builtin/clayGarden.ts +114 -0
- package/vendor/wajha/templates/builtin/desertDune.ts +97 -0
- package/vendor/wajha/templates/builtin/glassPrism.ts +139 -0
- package/vendor/wajha/templates/builtin/launchpad.ts +140 -0
- package/vendor/wajha/templates/builtin/loudAndClear.ts +109 -0
- package/vendor/wajha/templates/builtin/midnightNeon.ts +108 -0
- package/vendor/wajha/templates/builtin/monoSwiss.ts +149 -0
- package/vendor/wajha/templates/builtin/noirGold.ts +149 -0
- package/vendor/wajha/templates/builtin/noor.ts +107 -0
- package/vendor/wajha/templates/builtin/obsidian.ts +137 -0
- package/vendor/wajha/templates/builtin/panoramaPeaks.ts +145 -0
- package/vendor/wajha/templates/builtin/panoramaRibbon.ts +138 -0
- package/vendor/wajha/templates/builtin/panoramaWaves.ts +138 -0
- package/vendor/wajha/templates/builtin/paperAndInk.ts +98 -0
- package/vendor/wajha/templates/builtin/sage.ts +94 -0
- package/vendor/wajha/templates/builtin/splitTone.ts +129 -0
- package/vendor/wajha/templates/builtin/sunsetBoulevard.ts +127 -0
- package/vendor/wajha/templates/index.ts +55 -0
- package/vendor/wajha/templates/kit.ts +761 -0
- package/vendor/wajha/ui/sortShots.ts +239 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Numeric and path helpers shared by the engine's painters. Everything here
|
|
3
|
+
* is defensive: layouts come from user files, so NaN, negative sizes and
|
|
4
|
+
* huge values must be turned into something the canvas accepts (several
|
|
5
|
+
* Canvas 2D calls throw on negative radii).
|
|
6
|
+
*/
|
|
7
|
+
import type { Ctx2D } from './types';
|
|
8
|
+
|
|
9
|
+
export const DEG = Math.PI / 180;
|
|
10
|
+
export const TAU = Math.PI * 2;
|
|
11
|
+
|
|
12
|
+
export interface Rect {
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
w: number;
|
|
16
|
+
h: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const clamp = (v: number, lo: number, hi: number): number => (v < lo ? lo : v > hi ? hi : v);
|
|
20
|
+
|
|
21
|
+
/** `v` when it is a finite number, else `fallback`. */
|
|
22
|
+
export function num(v: unknown, fallback: number): number {
|
|
23
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : fallback;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Overlap of two rectangles, or null when they do not intersect. */
|
|
27
|
+
export function intersect(a: Rect, b: Rect): Rect | null {
|
|
28
|
+
const x = Math.max(a.x, b.x);
|
|
29
|
+
const y = Math.max(a.y, b.y);
|
|
30
|
+
const r = Math.min(a.x + a.w, b.x + b.w);
|
|
31
|
+
const bottom = Math.min(a.y + a.h, b.y + b.h);
|
|
32
|
+
return r > x && bottom > y ? { x, y, w: r - x, h: bottom - y } : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Adds a rounded-rectangle sub-path. Uses `ctx.roundRect` when present
|
|
37
|
+
* (missing in older WebKit) and an arc-based fallback otherwise; the radius
|
|
38
|
+
* is clamped to half the shorter side like CSS does.
|
|
39
|
+
*/
|
|
40
|
+
export function roundedRect(ctx: Ctx2D, x: number, y: number, w: number, h: number, r: number): void {
|
|
41
|
+
if (!(w > 0) || !(h > 0)) return;
|
|
42
|
+
const rr = clamp(num(r, 0), 0, Math.min(w, h) / 2);
|
|
43
|
+
if (rr <= 0) {
|
|
44
|
+
ctx.rect(x, y, w, h);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (typeof ctx.roundRect === 'function') {
|
|
48
|
+
ctx.roundRect(x, y, w, h, rr);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
ctx.moveTo(x + rr, y);
|
|
52
|
+
ctx.lineTo(x + w - rr, y);
|
|
53
|
+
ctx.arc(x + w - rr, y + rr, rr, -Math.PI / 2, 0);
|
|
54
|
+
ctx.lineTo(x + w, y + h - rr);
|
|
55
|
+
ctx.arc(x + w - rr, y + h - rr, rr, 0, Math.PI / 2);
|
|
56
|
+
ctx.lineTo(x + rr, y + h);
|
|
57
|
+
ctx.arc(x + rr, y + h - rr, rr, Math.PI / 2, Math.PI);
|
|
58
|
+
ctx.lineTo(x, y + rr);
|
|
59
|
+
ctx.arc(x + rr, y + rr, rr, Math.PI, Math.PI * 1.5);
|
|
60
|
+
ctx.closePath();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Adds an axis-aligned ellipse sub-path centred on (cx, cy); `ccw` reverses the winding (for rings). */
|
|
64
|
+
export function ellipsePath(ctx: Ctx2D, cx: number, cy: number, rx: number, ry: number, ccw = false): void {
|
|
65
|
+
if (!(rx > 0) || !(ry > 0)) return;
|
|
66
|
+
// A fresh sub-path, so a second ellipse (ring hole) is not joined to the first by a line.
|
|
67
|
+
ctx.moveTo(cx + rx, cy);
|
|
68
|
+
// The spec only guarantees a full turn when the sweep in the drawing direction is >= 2π,
|
|
69
|
+
// so counter-clockwise runs from 2π down to 0 rather than relying on angle normalisation.
|
|
70
|
+
if (ccw) ctx.ellipse(cx, cy, rx, ry, 0, TAU, 0, true);
|
|
71
|
+
else ctx.ellipse(cx, cy, rx, ry, 0, 0, TAU, false);
|
|
72
|
+
ctx.closePath();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface GradientLine {
|
|
76
|
+
x0: number;
|
|
77
|
+
y0: number;
|
|
78
|
+
x1: number;
|
|
79
|
+
y1: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Gradient line of a CSS `linear-gradient(<angle>)` over a box: it passes
|
|
84
|
+
* through the centre, 0deg points up, 90deg right, and its length is
|
|
85
|
+
* |w sin a| + |h cos a| so the corners get exactly the first/last stop.
|
|
86
|
+
*/
|
|
87
|
+
export function linearGradientLine(angleDeg: number, box: Rect): GradientLine {
|
|
88
|
+
const a = num(angleDeg, 180) * DEG;
|
|
89
|
+
const dx = Math.sin(a);
|
|
90
|
+
const dy = -Math.cos(a);
|
|
91
|
+
const half = (Math.abs(box.w * dx) + Math.abs(box.h * dy)) / 2;
|
|
92
|
+
const cx = box.x + box.w / 2;
|
|
93
|
+
const cy = box.y + box.h / 2;
|
|
94
|
+
return { x0: cx - dx * half, y0: cy - dy * half, x1: cx + dx * half, y1: cy + dy * half };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Deterministic PRNG (mulberry32): same seed, same sequence, on every platform. */
|
|
98
|
+
export function mulberry32(seed: number): () => number {
|
|
99
|
+
let a = seed >>> 0;
|
|
100
|
+
return () => {
|
|
101
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
102
|
+
let t = a;
|
|
103
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
104
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
105
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Integer seed from any number; NaN and Infinity become 0. */
|
|
110
|
+
export function seedInt(seed: unknown): number {
|
|
111
|
+
const s = num(seed, 0);
|
|
112
|
+
return Math.abs(s) < 2 ** 31 ? Math.floor(s) | 0 : (Math.floor(s % 2 ** 32) | 0);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Stateless hash of a lattice point to [0, 1); used for film grain. */
|
|
116
|
+
export function hash2(x: number, y: number, seed = 0): number {
|
|
117
|
+
let h = Math.imul(x | 0, 0x27d4eb2d) ^ Math.imul(y | 0, 0x165667b1) ^ Math.imul(seed | 0, 0x9e3779b1);
|
|
118
|
+
h = Math.imul(h ^ (h >>> 15), 0x85ebca6b);
|
|
119
|
+
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
|
|
120
|
+
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Runs `draw` with bilinear image sampling. Soft content (mesh bitmaps,
|
|
125
|
+
* glows, blurred images) and ~1:1 pattern tiles look identical to the
|
|
126
|
+
* 'high' quality filter but cost a fraction of it on CPU rasterisers.
|
|
127
|
+
*/
|
|
128
|
+
export function withFastSampling(ctx: Ctx2D, draw: () => void): void {
|
|
129
|
+
const prev = ctx.imageSmoothingQuality;
|
|
130
|
+
ctx.imageSmoothingQuality = 'low';
|
|
131
|
+
try {
|
|
132
|
+
draw();
|
|
133
|
+
} finally {
|
|
134
|
+
ctx.imageSmoothingQuality = prev;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Uniform scale of the context's current transform (device pixels per user
|
|
140
|
+
* unit). Shadow blur and offsets ignore the transform, so painters multiply
|
|
141
|
+
* them by this to stay proportional when a caller renders scaled.
|
|
142
|
+
*/
|
|
143
|
+
export function transformScale(ctx: Ctx2D): number {
|
|
144
|
+
if (typeof ctx.getTransform !== 'function') return 1;
|
|
145
|
+
try {
|
|
146
|
+
const t = ctx.getTransform();
|
|
147
|
+
const s = Math.sqrt(Math.abs(t.a * t.d - t.b * t.c));
|
|
148
|
+
return Number.isFinite(s) && s > 1e-6 ? s : 1;
|
|
149
|
+
} catch {
|
|
150
|
+
return 1;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image elements: a picture `width x u` wide with the asset's aspect, clipped
|
|
3
|
+
* to rounded corners, with an optional shadow. The shadow follows CSS
|
|
4
|
+
* box-shadow rules: it is cast by the rounded box and only shows outside
|
|
5
|
+
* it, so a transparent PNG never reveals a dark rectangle behind itself.
|
|
6
|
+
*/
|
|
7
|
+
import type { ImageElement } from '../model/types';
|
|
8
|
+
import type { Ctx2D, ImageLike } from './types';
|
|
9
|
+
import { cssColor } from './paint';
|
|
10
|
+
import { clamp, num, roundedRect } from './geometry';
|
|
11
|
+
|
|
12
|
+
/** Fill of an image element whose picture is not decoded (or missing). */
|
|
13
|
+
export const MISSING_IMAGE_FILL = 'rgba(148, 154, 166, 0.22)';
|
|
14
|
+
|
|
15
|
+
export interface ImageSources {
|
|
16
|
+
getImage(assetId: string): ImageLike | null;
|
|
17
|
+
getAssetAspect(assetId: string): number | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function safeCall<T>(fn: () => T, fallback: T): T {
|
|
21
|
+
try {
|
|
22
|
+
return fn();
|
|
23
|
+
} catch {
|
|
24
|
+
return fallback;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Decoded image (or null) and height/width aspect of an image element's asset. */
|
|
29
|
+
export function resolveImage(el: ImageElement, src: ImageSources): { image: ImageLike | null; aspect: number } {
|
|
30
|
+
const id = typeof el.assetId === 'string' ? el.assetId : '';
|
|
31
|
+
const raw = id ? safeCall(() => src.getImage(id), null) : null;
|
|
32
|
+
const image = raw && raw.width > 0 && raw.height > 0 ? raw : null;
|
|
33
|
+
const known = id ? safeCall(() => src.getAssetAspect(id), null) : null;
|
|
34
|
+
let aspect = num(known, 0);
|
|
35
|
+
if (!(aspect > 0) && image) aspect = image.height / image.width;
|
|
36
|
+
if (!(aspect > 0) || !Number.isFinite(aspect)) aspect = 1;
|
|
37
|
+
return { image, aspect: clamp(aspect, 0.02, 50) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Draws an image element centred on the origin. Returns its unrotated size
|
|
42
|
+
* in pixels. `pxScale` converts shadow sizes to device pixels.
|
|
43
|
+
*/
|
|
44
|
+
export function drawImageElement(ctx: Ctx2D, el: ImageElement, u: number, pxScale: number, src: ImageSources): { w: number; h: number } {
|
|
45
|
+
const { image, aspect } = resolveImage(el, src);
|
|
46
|
+
const w = Math.max(0, num(el.width, 0) * u);
|
|
47
|
+
const h = w * aspect;
|
|
48
|
+
if (!(w >= 0.5) || !(h >= 0.5) || !Number.isFinite(w * h)) return { w, h };
|
|
49
|
+
const x = -w / 2;
|
|
50
|
+
const y = -h / 2;
|
|
51
|
+
const r = clamp(num(el.cornerRadius, 0), 0, 0.5) * w;
|
|
52
|
+
|
|
53
|
+
if (!image) {
|
|
54
|
+
ctx.fillStyle = MISSING_IMAGE_FILL;
|
|
55
|
+
ctx.beginPath();
|
|
56
|
+
roundedRect(ctx, x, y, w, h, r);
|
|
57
|
+
ctx.fill();
|
|
58
|
+
return { w, h };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sh = el.shadow;
|
|
62
|
+
if (sh && typeof sh === 'object') {
|
|
63
|
+
ctx.save();
|
|
64
|
+
try {
|
|
65
|
+
// Clip to everything outside the box; the filled box itself is then invisible and
|
|
66
|
+
// only its shadow remains (no off-canvas trick needed, so rotation is free).
|
|
67
|
+
const big = 1e6;
|
|
68
|
+
ctx.beginPath();
|
|
69
|
+
ctx.rect(-big, -big, 2 * big, 2 * big);
|
|
70
|
+
roundedRect(ctx, x, y, w, h, r);
|
|
71
|
+
ctx.clip('evenodd');
|
|
72
|
+
ctx.shadowColor = cssColor(sh.color);
|
|
73
|
+
ctx.shadowBlur = Math.max(0, num(sh.blur, 0)) * u * pxScale;
|
|
74
|
+
ctx.shadowOffsetX = num(sh.x, 0) * u * pxScale;
|
|
75
|
+
ctx.shadowOffsetY = num(sh.y, 0) * u * pxScale;
|
|
76
|
+
ctx.fillStyle = '#000000';
|
|
77
|
+
ctx.beginPath();
|
|
78
|
+
roundedRect(ctx, x, y, w, h, r);
|
|
79
|
+
ctx.fill();
|
|
80
|
+
} finally {
|
|
81
|
+
ctx.restore();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
ctx.save();
|
|
86
|
+
try {
|
|
87
|
+
ctx.beginPath();
|
|
88
|
+
roundedRect(ctx, x, y, w, h, r);
|
|
89
|
+
ctx.clip();
|
|
90
|
+
// Throws for a bitmap the image cache closed meanwhile; the finally keeps the stack balanced.
|
|
91
|
+
ctx.drawImage(image, x, y, w, h);
|
|
92
|
+
} finally {
|
|
93
|
+
ctx.restore();
|
|
94
|
+
}
|
|
95
|
+
return { w, h };
|
|
96
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine entry point. Implementations live in sibling modules:
|
|
3
|
+
* render.ts renderSlide (scene composition)
|
|
4
|
+
* background.ts slide backgrounds incl. panorama windows
|
|
5
|
+
* paint.ts the Fill painter (solid, linear, radial, mesh, image)
|
|
6
|
+
* patterns.ts background patterns
|
|
7
|
+
* shapes.ts circle / rect / blob / ring, soft glows
|
|
8
|
+
* images.ts image elements
|
|
9
|
+
* text.ts layoutText / drawTextBlock (markup.ts, linebreak.ts, bidi.ts)
|
|
10
|
+
* devices/ deviceGeometry / drawDevice
|
|
11
|
+
* mock.ts drawMockScreen
|
|
12
|
+
* cache.ts bounded caches shared by the painters
|
|
13
|
+
*/
|
|
14
|
+
export type * from './types';
|
|
15
|
+
export { deviceScreenAspect, mockSeed, renderSlide } from './render';
|
|
16
|
+
export { layoutText } from './text';
|
|
17
|
+
export { deviceGeometry, drawDevice } from './devices';
|
|
18
|
+
export { drawMockScreen } from './mock';
|
|
19
|
+
export { parseMarkup, plainText } from './markup';
|
|
20
|
+
export { clearEngineCaches } from './cache';
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a paragraph may wrap. A simplified UAX #14:
|
|
3
|
+
* - after spaces (the spaces hang at the line end and are not drawn),
|
|
4
|
+
* - after a hyphen inside a word ("state-of-the-art"),
|
|
5
|
+
* - around ideographs, kana and fullwidth forms in any language,
|
|
6
|
+
* - between any graphemes for languages marked `wrap: 'char'`, except
|
|
7
|
+
* inside runs of Latin-like letters and digits, so an app name inside
|
|
8
|
+
* Japanese copy stays whole; Thai uses dictionary word boundaries from
|
|
9
|
+
* Intl.Segmenter when available,
|
|
10
|
+
* - never before closing punctuation or small kana, never after opening
|
|
11
|
+
* punctuation (basic kinsoku shori).
|
|
12
|
+
* Graphemes come from Intl.Segmenter, with a code-point fallback that keeps
|
|
13
|
+
* combining marks, variation selectors, ZWJ emoji and flags together, so a
|
|
14
|
+
* forced break can never split a user-perceived character.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Break class of a grapheme. */
|
|
18
|
+
const WSP = 0; // breaking whitespace
|
|
19
|
+
const ZW = 1; // zero-width space: break opportunity
|
|
20
|
+
const CL = 2; // closing punctuation: no break before
|
|
21
|
+
const OP = 3; // opening punctuation: no break after
|
|
22
|
+
const HY = 4; // hyphen: break after (inside words)
|
|
23
|
+
const ID = 5; // ideographic: break before and after
|
|
24
|
+
const SA = 6; // Thai/Lao/Khmer/Myanmar: no spaces between words
|
|
25
|
+
const AL = 7; // letters, digits, everything else: no break inside
|
|
26
|
+
|
|
27
|
+
export interface Segment {
|
|
28
|
+
/** First code unit of the segment. */
|
|
29
|
+
start: number;
|
|
30
|
+
/** End of the visible content (exclusive), before any trailing spaces. */
|
|
31
|
+
end: number;
|
|
32
|
+
/** End including trailing spaces. */
|
|
33
|
+
wsEnd: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Segmentation {
|
|
37
|
+
/** Grapheme start offsets, followed by text.length. */
|
|
38
|
+
graphemes: number[];
|
|
39
|
+
segments: Segment[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type Granularity = 'grapheme' | 'word';
|
|
43
|
+
const segmenters = new Map<string, Intl.Segmenter | null>();
|
|
44
|
+
|
|
45
|
+
function segmenter(lang: string, granularity: Granularity): Intl.Segmenter | null {
|
|
46
|
+
const key = `${granularity}|${lang}`;
|
|
47
|
+
const hit = segmenters.get(key);
|
|
48
|
+
if (hit !== undefined) return hit;
|
|
49
|
+
let seg: Intl.Segmenter | null = null;
|
|
50
|
+
if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
|
|
51
|
+
try {
|
|
52
|
+
seg = new Intl.Segmenter(lang || undefined, { granularity });
|
|
53
|
+
} catch {
|
|
54
|
+
// Invalid language tags throw RangeError; fall back to the default locale.
|
|
55
|
+
try {
|
|
56
|
+
seg = new Intl.Segmenter(undefined, { granularity });
|
|
57
|
+
} catch {
|
|
58
|
+
seg = null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (segmenters.size > 64) segmenters.clear();
|
|
63
|
+
segmenters.set(key, seg);
|
|
64
|
+
return seg;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const isExtender = (cp: number) =>
|
|
68
|
+
(cp >= 0x0300 && cp <= 0x036f) || // combining diacritics
|
|
69
|
+
(cp >= 0x0483 && cp <= 0x0489) ||
|
|
70
|
+
(cp >= 0x0591 && cp <= 0x05c7 && cp !== 0x05be && cp !== 0x05c0 && cp !== 0x05c3 && cp !== 0x05c6) ||
|
|
71
|
+
(cp >= 0x0610 && cp <= 0x061a) ||
|
|
72
|
+
(cp >= 0x064b && cp <= 0x065f) ||
|
|
73
|
+
cp === 0x0670 ||
|
|
74
|
+
(cp >= 0x06d6 && cp <= 0x06ed && cp !== 0x06dd && cp !== 0x06de && cp !== 0x06e5 && cp !== 0x06e6 && cp !== 0x06e9) ||
|
|
75
|
+
(cp >= 0x0900 && cp <= 0x0903) ||
|
|
76
|
+
(cp >= 0x093a && cp <= 0x094f) ||
|
|
77
|
+
cp === 0x0e31 ||
|
|
78
|
+
(cp >= 0x0e34 && cp <= 0x0e3a) ||
|
|
79
|
+
(cp >= 0x0e47 && cp <= 0x0e4e) ||
|
|
80
|
+
(cp >= 0x1ab0 && cp <= 0x1aff) ||
|
|
81
|
+
(cp >= 0x1dc0 && cp <= 0x1dff) ||
|
|
82
|
+
cp === 0x200c ||
|
|
83
|
+
cp === 0x200d ||
|
|
84
|
+
(cp >= 0x20d0 && cp <= 0x20ff) ||
|
|
85
|
+
(cp >= 0x3099 && cp <= 0x309a) ||
|
|
86
|
+
(cp >= 0xfe00 && cp <= 0xfe0f) ||
|
|
87
|
+
(cp >= 0xfe20 && cp <= 0xfe2f) ||
|
|
88
|
+
(cp >= 0x1f3fb && cp <= 0x1f3ff) || // skin tones
|
|
89
|
+
(cp >= 0xe0020 && cp <= 0xe007f) || // emoji tags
|
|
90
|
+
(cp >= 0xe0100 && cp <= 0xe01ef);
|
|
91
|
+
|
|
92
|
+
const isRegionalIndicator = (cp: number) => cp >= 0x1f1e6 && cp <= 0x1f1ff;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Grapheme starts without Intl.Segmenter (older WebKit): combining marks,
|
|
96
|
+
* variation selectors, skin tones, ZWJ sequences and flag pairs stay with
|
|
97
|
+
* the character before them. Followed by text.length.
|
|
98
|
+
*/
|
|
99
|
+
export function fallbackGraphemeBounds(text: string): number[] {
|
|
100
|
+
const out: number[] = [];
|
|
101
|
+
let prevZwj = false;
|
|
102
|
+
let riRun = 0;
|
|
103
|
+
for (let i = 0; i < text.length; ) {
|
|
104
|
+
const cp = text.codePointAt(i) ?? 0;
|
|
105
|
+
const ri = isRegionalIndicator(cp);
|
|
106
|
+
// Flags are pairs of regional indicators: the 2nd, 4th, ... join the one before.
|
|
107
|
+
const joins = i > 0 && (isExtender(cp) || prevZwj || (ri && riRun % 2 === 1));
|
|
108
|
+
if (!joins) out.push(i);
|
|
109
|
+
riRun = ri ? riRun + 1 : 0;
|
|
110
|
+
prevZwj = cp === 0x200d;
|
|
111
|
+
i += cp > 0xffff ? 2 : 1;
|
|
112
|
+
}
|
|
113
|
+
out.push(text.length);
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Grapheme start offsets (UTF-16) followed by text.length. */
|
|
118
|
+
export function graphemeBounds(text: string, lang: string): number[] {
|
|
119
|
+
if (!text) return [0];
|
|
120
|
+
const seg = segmenter(lang, 'grapheme');
|
|
121
|
+
if (!seg) return fallbackGraphemeBounds(text);
|
|
122
|
+
const out: number[] = [];
|
|
123
|
+
for (const s of seg.segment(text)) out.push(s.index);
|
|
124
|
+
out.push(text.length);
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Splits a string into graphemes. */
|
|
129
|
+
export function graphemes(text: string, lang: string): string[] {
|
|
130
|
+
const b = graphemeBounds(text, lang);
|
|
131
|
+
const out: string[] = [];
|
|
132
|
+
for (let i = 0; i + 1 < b.length; i++) out.push(text.slice(b[i], b[i + 1]));
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const CLOSERS = new Set(
|
|
137
|
+
[...')]}»›”’"\'!?,.:;…‼⁇⁈⁉、。,.!?:;)]}〕〉》」』】〙〗〟⦆ー々〻ぁぃぅぇぉっゃゅょゎゕゖァィゥェォッャュョヮヵヶㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ・‥゛゜ゝゞヽヾ%‰°′″℃¢؟،؛'].map(
|
|
138
|
+
(c) => c.codePointAt(0) ?? 0,
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
const OPENERS = new Set([...'([{«‹“‘([{〔〈《「『【〘〖〝⦅'].map((c) => c.codePointAt(0) ?? 0));
|
|
142
|
+
|
|
143
|
+
const LETTER = /\p{L}/u;
|
|
144
|
+
|
|
145
|
+
function breakClass(cp: number): number {
|
|
146
|
+
if (cp === 0x20 || cp === 0x09 || cp === 0x3000 || cp === 0x1680 || (cp >= 0x2000 && cp <= 0x2006) || (cp >= 0x2008 && cp <= 0x200a) || cp === 0x205f) return WSP;
|
|
147
|
+
if (cp === 0x200b) return ZW;
|
|
148
|
+
if (CLOSERS.has(cp)) return CL;
|
|
149
|
+
if (OPENERS.has(cp)) return OP;
|
|
150
|
+
if (cp === 0x2d || cp === 0x2010 || cp === 0x2013 || cp === 0x2014) return HY;
|
|
151
|
+
if (
|
|
152
|
+
(cp >= 0x2e80 && cp <= 0x2fff) ||
|
|
153
|
+
(cp >= 0x3001 && cp <= 0x303f) ||
|
|
154
|
+
(cp >= 0x3040 && cp <= 0x30ff) ||
|
|
155
|
+
(cp >= 0x3100 && cp <= 0x31ff) ||
|
|
156
|
+
(cp >= 0x3200 && cp <= 0x4dbf) ||
|
|
157
|
+
(cp >= 0x4e00 && cp <= 0x9fff) ||
|
|
158
|
+
(cp >= 0xa000 && cp <= 0xa4cf) ||
|
|
159
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
160
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) ||
|
|
161
|
+
(cp >= 0xff00 && cp <= 0xffef) ||
|
|
162
|
+
(cp >= 0x1f300 && cp <= 0x1faff) ||
|
|
163
|
+
(cp >= 0x2600 && cp <= 0x27bf) ||
|
|
164
|
+
(cp >= 0x20000 && cp <= 0x3ffff)
|
|
165
|
+
)
|
|
166
|
+
return ID;
|
|
167
|
+
if ((cp >= 0x0e00 && cp <= 0x0eff) || (cp >= 0x1000 && cp <= 0x109f) || (cp >= 0x1780 && cp <= 0x17ff) || (cp >= 0x1950 && cp <= 0x19df)) return SA;
|
|
168
|
+
return AL;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Splits a paragraph into unbreakable segments (each followed by its
|
|
173
|
+
* trailing spaces). Leading spaces of the paragraph are dropped.
|
|
174
|
+
*/
|
|
175
|
+
export function segmentText(text: string, lang: string, mode: 'space' | 'char'): Segmentation {
|
|
176
|
+
const bounds = graphemeBounds(text, lang);
|
|
177
|
+
const count = bounds.length - 1;
|
|
178
|
+
const cls = new Uint8Array(count);
|
|
179
|
+
let hasSA = false;
|
|
180
|
+
for (let i = 0; i < count; i++) {
|
|
181
|
+
cls[i] = breakClass(text.codePointAt(bounds[i]) ?? 0);
|
|
182
|
+
if (cls[i] === SA) hasSA = true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Dictionary word boundaries for scripts written without spaces.
|
|
186
|
+
let wordStarts: Set<number> | null = null;
|
|
187
|
+
if (mode === 'char' && hasSA) {
|
|
188
|
+
const seg = segmenter(lang, 'word');
|
|
189
|
+
if (seg) {
|
|
190
|
+
wordStarts = new Set();
|
|
191
|
+
for (const s of seg.segment(text)) wordStarts.add(s.index);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const isLetter = (g: number) => LETTER.test(String.fromCodePoint(text.codePointAt(bounds[g]) ?? 0));
|
|
196
|
+
const latinLike = (g: number) => cls[g] === AL || ((text.codePointAt(bounds[g]) ?? 0x80) < 0x80 && cls[g] !== WSP);
|
|
197
|
+
const canBreak = (a: number, b: number): boolean => {
|
|
198
|
+
const A = cls[a];
|
|
199
|
+
const B = cls[b];
|
|
200
|
+
if (A === ZW) return true;
|
|
201
|
+
if (B === CL || B === ZW) return false;
|
|
202
|
+
if (A === OP) return false;
|
|
203
|
+
// "state-of-the-art" may wrap after a hyphen; "1-2" and "-5" may not.
|
|
204
|
+
if (A === HY) return B === AL && a > 0 && cls[a - 1] === AL && isLetter(b) && isLetter(a - 1);
|
|
205
|
+
if (A === ID || B === ID) return true;
|
|
206
|
+
if (mode !== 'char') return false;
|
|
207
|
+
if (A === SA && B === SA) return wordStarts ? wordStarts.has(bounds[b]) : true;
|
|
208
|
+
// Latin words, numbers and their ASCII punctuation ("it's", "v2.0") stay whole in CJK text.
|
|
209
|
+
return !(latinLike(a) && latinLike(b)) && !(A === HY || B === HY);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const segments: Segment[] = [];
|
|
213
|
+
let i = 0;
|
|
214
|
+
while (i < count && cls[i] === WSP) i++;
|
|
215
|
+
while (i < count) {
|
|
216
|
+
const start = bounds[i];
|
|
217
|
+
let j = i + 1;
|
|
218
|
+
while (j < count && cls[j] !== WSP && !canBreak(j - 1, j)) j++;
|
|
219
|
+
const end = bounds[j];
|
|
220
|
+
let k = j;
|
|
221
|
+
while (k < count && cls[k] === WSP) k++;
|
|
222
|
+
segments.push({ start, end, wsEnd: bounds[k] });
|
|
223
|
+
i = k;
|
|
224
|
+
}
|
|
225
|
+
return { graphemes: bounds, segments };
|
|
226
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slide text markup. Users type plain text with three conventions:
|
|
3
|
+
* *word* highlighted run (toggles, so "*a* b *c*" highlights a and c)
|
|
4
|
+
* \* a literal asterisk
|
|
5
|
+
* newline hard line break (CR LF, CR and the Unicode line/paragraph
|
|
6
|
+
* separators are treated the same, since CSV imports carry them)
|
|
7
|
+
* When the number of unescaped asterisks is odd, the last one is literal, so
|
|
8
|
+
* "5* rating" and "*unfinished" never turn the rest of a text into a highlight.
|
|
9
|
+
*/
|
|
10
|
+
import { engineCache } from './cache';
|
|
11
|
+
|
|
12
|
+
export interface MarkupRun {
|
|
13
|
+
readonly text: string;
|
|
14
|
+
readonly highlighted: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** One hard-broken line of text; empty for blank lines. */
|
|
18
|
+
export type MarkupParagraph = readonly MarkupRun[];
|
|
19
|
+
|
|
20
|
+
const parsed = engineCache<string, readonly MarkupParagraph[]>(256);
|
|
21
|
+
|
|
22
|
+
/** Parses markup into paragraphs of runs. Adjacent runs never share the same highlight state. */
|
|
23
|
+
export function parseMarkup(text: string): readonly MarkupParagraph[] {
|
|
24
|
+
const source = typeof text === 'string' ? text : '';
|
|
25
|
+
const hit = parsed.get(source);
|
|
26
|
+
if (hit) return hit;
|
|
27
|
+
|
|
28
|
+
const src = source.replace(/\r\n?|[\u2028\u2029]/g, '\n');
|
|
29
|
+
let toggles = 0;
|
|
30
|
+
for (let i = 0; i < src.length; i++) {
|
|
31
|
+
if (src[i] === '\\' && src[i + 1] === '*') i++;
|
|
32
|
+
else if (src[i] === '*') toggles++;
|
|
33
|
+
}
|
|
34
|
+
const lastIsLiteral = toggles % 2 === 1;
|
|
35
|
+
|
|
36
|
+
const paragraphs: MarkupParagraph[] = [];
|
|
37
|
+
let runs: MarkupRun[] = [];
|
|
38
|
+
let buf = '';
|
|
39
|
+
let highlighted = false;
|
|
40
|
+
let seen = 0;
|
|
41
|
+
const flush = () => {
|
|
42
|
+
if (!buf) return;
|
|
43
|
+
const last = runs[runs.length - 1];
|
|
44
|
+
// Empty toggles ("a**b") would otherwise leave two plain runs side by side.
|
|
45
|
+
if (last && last.highlighted === highlighted) runs[runs.length - 1] = { text: last.text + buf, highlighted };
|
|
46
|
+
else runs.push({ text: buf, highlighted });
|
|
47
|
+
buf = '';
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
for (let i = 0; i < src.length; i++) {
|
|
51
|
+
const ch = src[i];
|
|
52
|
+
if (ch === '\\' && src[i + 1] === '*') {
|
|
53
|
+
buf += '*';
|
|
54
|
+
i++;
|
|
55
|
+
} else if (ch === '*') {
|
|
56
|
+
seen++;
|
|
57
|
+
if (lastIsLiteral && seen === toggles) {
|
|
58
|
+
buf += '*';
|
|
59
|
+
} else {
|
|
60
|
+
flush();
|
|
61
|
+
highlighted = !highlighted;
|
|
62
|
+
}
|
|
63
|
+
} else if (ch === '\n') {
|
|
64
|
+
flush();
|
|
65
|
+
paragraphs.push(Object.freeze(runs.map((r) => Object.freeze(r))));
|
|
66
|
+
runs = [];
|
|
67
|
+
} else {
|
|
68
|
+
buf += ch;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
flush();
|
|
72
|
+
paragraphs.push(Object.freeze(runs.map((r) => Object.freeze(r))));
|
|
73
|
+
|
|
74
|
+
const result = Object.freeze(paragraphs);
|
|
75
|
+
parsed.set(source, result);
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The text with markup removed (for character counts, accessibility labels, search). */
|
|
80
|
+
export function plainText(text: string): string {
|
|
81
|
+
return parseMarkup(text)
|
|
82
|
+
.map((p) => p.map((r) => r.text).join(''))
|
|
83
|
+
.join('\n');
|
|
84
|
+
}
|