@tongue-core/engine 0.1.0-rc.2 → 0.1.0-rc.3
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 +4 -0
- package/dist/assets/store.d.ts +5 -0
- package/dist/assets/store.js +9 -1
- package/dist/evaluate/evaluate.js +123 -10
- package/dist/evaluate/tree.d.ts +24 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/layout/layout.js +130 -82
- package/dist/paint/geometry.d.ts +12 -4
- package/dist/paint/geometry.js +25 -11
- package/dist/paint/list.d.ts +28 -1
- package/dist/paint/list.js +26 -2
- package/dist/paint/morph.js +11 -7
- package/dist/paint/paint.d.ts +18 -7
- package/dist/paint/paint.js +181 -16
- package/dist/paint/stroke.d.ts +26 -0
- package/dist/paint/stroke.js +233 -0
- package/dist/raster/raster.d.ts +3 -0
- package/dist/raster/raster.js +5 -0
- package/dist/raster/wasm-bytes.js +2 -2
- package/dist/render/compose.d.ts +1 -1
- package/dist/render/compose.js +33 -11
- package/dist/render/renderer.d.ts +4 -0
- package/dist/render/renderer.js +21 -2
- package/dist/style/computed.d.ts +14 -1
- package/dist/style/parse.js +2 -4
- package/dist/style/resolve.js +53 -4
- package/dist/text/fonts.d.ts +4 -1
- package/dist/text/fonts.js +9 -1
- package/dist/text/ttf.js +5 -1
- package/package.json +2 -2
- package/raster.wasm +0 -0
package/dist/render/compose.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type Child, type ClipPlan, type FilmPlan } from "@tongue-core/film/model";
|
|
2
2
|
import { type SceneEnv, type SceneFrame } from "../evaluate/scene-frame.js";
|
|
3
3
|
import type { Picture } from "../evaluate/tree.js";
|
|
4
|
-
import type
|
|
4
|
+
import { type Painter } from "../paint/paint.js";
|
|
5
5
|
/** A clip's picture at one of its frames. */
|
|
6
6
|
type ClipFrame = {
|
|
7
7
|
readonly kind: "scene";
|
package/dist/render/compose.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { node, sourceSeconds, trackAt } from "@tongue-core/film/model";
|
|
2
2
|
import { buildScene } from "../evaluate/scene-frame.js";
|
|
3
3
|
import { identity, mul, translate } from "../paint/geometry.js";
|
|
4
|
+
import { parseColor } from "../style/parse.js";
|
|
4
5
|
import { paintTransition } from "../paint/morph.js";
|
|
6
|
+
import { premul } from "../paint/paint.js";
|
|
5
7
|
/** The markup a scene or inline clip draws (scenes render inside the frame's time, so lazily). */
|
|
6
8
|
export function contentOf(clip) {
|
|
7
9
|
const s = clip.source;
|
|
@@ -25,12 +27,12 @@ export function clipFrame(clip, local, env) {
|
|
|
25
27
|
throw new Error(`Clip "${clip.id}" plays audio; it has no picture.`);
|
|
26
28
|
}
|
|
27
29
|
/** Draws a clip's frame, faded by `alpha`, moved by `outer` (surface pixels). */
|
|
28
|
-
function draw(p, f, env, alpha, outer = identity) {
|
|
30
|
+
function draw(p, f, env, alpha, blend, outer = identity) {
|
|
29
31
|
if (alpha <= 0.001)
|
|
30
32
|
return;
|
|
31
|
-
const layered = alpha < 0.999;
|
|
33
|
+
const layered = alpha < 0.999 || blend !== "normal";
|
|
32
34
|
if (layered)
|
|
33
|
-
p.layer(p.surfaceRect(), { opacity: alpha, brightness: 1, saturate: 1, blur: 0 });
|
|
35
|
+
p.layer(p.surfaceRect(), { opacity: alpha, brightness: 1, saturate: 1, blur: 0, blend });
|
|
34
36
|
if (f.kind === "scene")
|
|
35
37
|
p.scene(f.frame, {}, outer);
|
|
36
38
|
else
|
|
@@ -41,7 +43,7 @@ function draw(p, f, env, alpha, outer = identity) {
|
|
|
41
43
|
function transition(p, t, from, to, fromLocal, toLocal, elapsed, progress, env) {
|
|
42
44
|
const a = clipFrame(from, fromLocal, env);
|
|
43
45
|
const b = clipFrame(to, toLocal, env);
|
|
44
|
-
if (t.style === "morph" && a.kind === "scene" && b.kind === "scene" && from.opacity === 1 && to.opacity === 1) {
|
|
46
|
+
if (t.style === "morph" && a.kind === "scene" && b.kind === "scene" && from.opacity === 1 && to.opacity === 1 && from.blend === "normal" && to.blend === "normal") {
|
|
45
47
|
paintTransition(p, a.frame, b.frame, { elapsed, frames: t.frames, progress, fps: env.fps, others: t.others });
|
|
46
48
|
return;
|
|
47
49
|
}
|
|
@@ -49,18 +51,18 @@ function transition(p, t, from, to, fromLocal, toLocal, elapsed, progress, env)
|
|
|
49
51
|
switch (t.style) {
|
|
50
52
|
case "morph": // nothing keyed on one side (or a faded clip): a crossfade
|
|
51
53
|
case "fade":
|
|
52
|
-
draw(p, a, env, from.opacity);
|
|
53
|
-
draw(p, b, env, to.opacity * progress);
|
|
54
|
+
draw(p, a, env, from.opacity, from.blend);
|
|
55
|
+
draw(p, b, env, to.opacity * progress, to.blend);
|
|
54
56
|
return;
|
|
55
57
|
case "wipe": {
|
|
56
|
-
draw(p, a, env, from.opacity);
|
|
58
|
+
draw(p, a, env, from.opacity, from.blend);
|
|
57
59
|
const { w, h } = surface;
|
|
58
60
|
const shown = t.direction === "left" ? { x: w * (1 - progress), y: 0, w: w * progress, h }
|
|
59
61
|
: t.direction === "right" ? { x: 0, y: 0, w: w * progress, h }
|
|
60
62
|
: t.direction === "up" ? { x: 0, y: h * (1 - progress), w, h: h * progress }
|
|
61
63
|
: { x: 0, y: 0, w, h: h * progress };
|
|
62
64
|
p.clipSurface(shown);
|
|
63
|
-
draw(p, b, env, to.opacity);
|
|
65
|
+
draw(p, b, env, to.opacity, to.blend);
|
|
64
66
|
p.unclip();
|
|
65
67
|
return;
|
|
66
68
|
}
|
|
@@ -68,8 +70,28 @@ function transition(p, t, from, to, fromLocal, toLocal, elapsed, progress, env)
|
|
|
68
70
|
// The incoming clip moves in from the side opposite `direction`, pushing the other out.
|
|
69
71
|
const dx = t.direction === "left" ? -surface.w : t.direction === "right" ? surface.w : 0;
|
|
70
72
|
const dy = t.direction === "up" ? -surface.h : t.direction === "down" ? surface.h : 0;
|
|
71
|
-
draw(p, a, env, from.opacity, translate(dx * progress, dy * progress));
|
|
72
|
-
draw(p, b, env, to.opacity, translate(-dx * (1 - progress), -dy * (1 - progress)));
|
|
73
|
+
draw(p, a, env, from.opacity, from.blend, translate(dx * progress, dy * progress));
|
|
74
|
+
draw(p, b, env, to.opacity, to.blend, translate(-dx * (1 - progress), -dy * (1 - progress)));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
case "dissolve": {
|
|
78
|
+
// The outgoing clip stays whole; the incoming one shows through the noise, its edge on top.
|
|
79
|
+
const stage = { x: 0, y: 0, w: env.width, h: env.height };
|
|
80
|
+
const soft = 0.035;
|
|
81
|
+
const ridged = t.dissolve.noise === "ridged";
|
|
82
|
+
const threshold = -soft + (1 + 2 * soft) * progress;
|
|
83
|
+
const noise = (band, width, color) => ({ kind: "noise", ridged, band, size: t.dissolve.size, threshold, soft: width, color });
|
|
84
|
+
draw(p, a, env, from.opacity, from.blend);
|
|
85
|
+
const plain = { opacity: 1, brightness: 1, saturate: 1, blur: 0 };
|
|
86
|
+
p.layer(surface, plain);
|
|
87
|
+
draw(p, b, env, to.opacity, to.blend);
|
|
88
|
+
p.layer(surface, plain);
|
|
89
|
+
p.fillStage(stage, noise(false, soft, { r: 1, g: 1, b: 1, a: 1 }));
|
|
90
|
+
p.popMask(false);
|
|
91
|
+
p.popLayer();
|
|
92
|
+
const edge = t.dissolve.edge;
|
|
93
|
+
if (edge !== null && progress > 0 && progress < 1)
|
|
94
|
+
p.fillStage(stage, noise(true, soft * 2, premul(parseColor(edge))));
|
|
73
95
|
return;
|
|
74
96
|
}
|
|
75
97
|
}
|
|
@@ -81,7 +103,7 @@ export function composeFrame(p, plan, frame, env) {
|
|
|
81
103
|
continue;
|
|
82
104
|
const state = trackAt(track, frame);
|
|
83
105
|
if (state.kind === "clip")
|
|
84
|
-
draw(p, clipFrame(state.clip, state.local, env), env, state.clip.opacity);
|
|
106
|
+
draw(p, clipFrame(state.clip, state.local, env), env, state.clip.opacity, state.clip.blend);
|
|
85
107
|
else if (state.kind === "transition")
|
|
86
108
|
transition(p, state.transition, state.from, state.to, state.fromLocal, state.toLocal, state.elapsed, state.progress, env);
|
|
87
109
|
}
|
|
@@ -65,6 +65,10 @@ export declare class FilmRenderer {
|
|
|
65
65
|
/** Puts an image in the raster now (decoded, mip-mapped), so the first frame that shows it doesn't
|
|
66
66
|
* wait for that: what a player does with an image as it arrives. */
|
|
67
67
|
preloadImage(name: string): void;
|
|
68
|
+
/** Keeps only the images `out` draws in the raster and returns their names. Decoded images are
|
|
69
|
+
* the raster's biggest cost, so a host with little memory (a Worker) calls this before drawing,
|
|
70
|
+
* and drops the files of everything else. A later frame that shows one loads it again. */
|
|
71
|
+
keepOnly(out: DrawnFrame): ReadonlyArray<string>;
|
|
68
72
|
/** Renders a frame into the raster; read it with rgba8(), yuv420() or png(). */
|
|
69
73
|
render(frame: number, options?: FrameOptions): void;
|
|
70
74
|
/** Rasterizes a frame already built with displayList(). */
|
package/dist/render/renderer.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { sourceSeconds, trackAt } from "@tongue-core/film/model";
|
|
2
|
+
import { encodeQoi } from "../assets/codecs/qoi.js";
|
|
2
3
|
import { imageOf, imageSize, videoOf } from "../assets/store.js";
|
|
3
4
|
import { frameAtSeconds, PLACEHOLDER_SIZE, placeholderFrame, postersOnly } from "../media/video.js";
|
|
4
5
|
import { scaling } from "../paint/geometry.js";
|
|
@@ -12,6 +13,9 @@ export function sizeAtScale(film, scale) {
|
|
|
12
13
|
const even = (v) => Math.max(2, Math.round((v * scale) / 2) * 2);
|
|
13
14
|
return { width: even(film.width), height: even(film.height) };
|
|
14
15
|
}
|
|
16
|
+
const IMAGE_KEY = "image:";
|
|
17
|
+
/** What an image slot holds once its image is let go. */
|
|
18
|
+
const NOTHING = encodeQoi({ width: 1, height: 1, data: new Uint8Array(4) });
|
|
15
19
|
export class FilmRenderer {
|
|
16
20
|
plan;
|
|
17
21
|
assets;
|
|
@@ -77,7 +81,7 @@ export class FilmRenderer {
|
|
|
77
81
|
const slotOf = (picture) => {
|
|
78
82
|
if (picture.kind === "image") {
|
|
79
83
|
const image = imageOf(this.assets, picture.asset);
|
|
80
|
-
return { id: this.idOf(
|
|
84
|
+
return { id: this.idOf(`${IMAGE_KEY}${picture.asset.name}`), key: "image", qoi: () => image.qoi, width: image.width, height: image.height };
|
|
81
85
|
}
|
|
82
86
|
videoFrames.set(`${picture.asset.name}:${picture.frame}`, { asset: picture.asset, frame: picture.frame });
|
|
83
87
|
return this.videoSlot(picture.asset, picture.frame, source, slotIndex(picture.asset, picture.frame));
|
|
@@ -162,12 +166,27 @@ export class FilmRenderer {
|
|
|
162
166
|
const image = this.assets.images.get(name);
|
|
163
167
|
if (!image)
|
|
164
168
|
return;
|
|
165
|
-
const id = this.idOf(
|
|
169
|
+
const id = this.idOf(`${IMAGE_KEY}${name}`);
|
|
166
170
|
if (this.loaded.get(id) === "image" && this.backend.hasImage(id))
|
|
167
171
|
return;
|
|
168
172
|
this.backend.loadImage(id, image.qoi);
|
|
169
173
|
this.loaded.set(id, "image");
|
|
170
174
|
}
|
|
175
|
+
/** Keeps only the images `out` draws in the raster and returns their names. Decoded images are
|
|
176
|
+
* the raster's biggest cost, so a host with little memory (a Worker) calls this before drawing,
|
|
177
|
+
* and drops the files of everything else. A later frame that shows one loads it again. */
|
|
178
|
+
keepOnly(out) {
|
|
179
|
+
const kept = [];
|
|
180
|
+
for (const [key, id] of this.imageIds) {
|
|
181
|
+
if (!key.startsWith(IMAGE_KEY))
|
|
182
|
+
continue;
|
|
183
|
+
if (out.images.has(id))
|
|
184
|
+
kept.push(key.slice(IMAGE_KEY.length));
|
|
185
|
+
else if (this.loaded.delete(id))
|
|
186
|
+
this.backend.loadImage(id, NOTHING); // replacing an image frees it
|
|
187
|
+
}
|
|
188
|
+
return kept;
|
|
189
|
+
}
|
|
171
190
|
/** Renders a frame into the raster; read it with rgba8(), yuv420() or png(). */
|
|
172
191
|
render(frame, options = {}) {
|
|
173
192
|
this.draw(this.displayList(frame, options));
|
package/dist/style/computed.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Color, FontAsset } from "@tongue-core/film/model";
|
|
1
|
+
import type { BlendMode, Color, FontAsset } from "@tongue-core/film/model";
|
|
2
2
|
export type { Color };
|
|
3
3
|
export interface GradientStop {
|
|
4
4
|
readonly offset: number;
|
|
@@ -23,6 +23,8 @@ export interface Shadow {
|
|
|
23
23
|
readonly blur: number;
|
|
24
24
|
readonly spread: number;
|
|
25
25
|
readonly color: Color;
|
|
26
|
+
/** Cast inside the box instead of outside it. */
|
|
27
|
+
readonly inset: boolean;
|
|
26
28
|
}
|
|
27
29
|
export interface Side {
|
|
28
30
|
readonly width: number;
|
|
@@ -58,6 +60,9 @@ export interface TextStyle {
|
|
|
58
60
|
readonly font: FontAsset | null;
|
|
59
61
|
readonly fontSize: number;
|
|
60
62
|
readonly fontWeight: number;
|
|
63
|
+
readonly italic: boolean;
|
|
64
|
+
readonly axes: Readonly<Record<string, number>>;
|
|
65
|
+
readonly shadows: ReadonlyArray<Shadow>;
|
|
61
66
|
readonly lineHeight: LineHeight;
|
|
62
67
|
readonly letterSpacing: number;
|
|
63
68
|
readonly textAlign: "left" | "center" | "right";
|
|
@@ -71,6 +76,8 @@ export interface Transform {
|
|
|
71
76
|
readonly rotate: number;
|
|
72
77
|
readonly turn: number;
|
|
73
78
|
readonly tilt: number;
|
|
79
|
+
/** Where the box pivots, as fractions of its size. */
|
|
80
|
+
readonly origin: readonly [number, number];
|
|
74
81
|
}
|
|
75
82
|
export interface Computed {
|
|
76
83
|
readonly display: Display;
|
|
@@ -112,6 +119,12 @@ export interface Computed {
|
|
|
112
119
|
readonly blur: number;
|
|
113
120
|
readonly brightness: number;
|
|
114
121
|
readonly saturate: number;
|
|
122
|
+
/** Camera distance for direct children that turn or tilt; null: they turn alone. */
|
|
123
|
+
readonly perspective: number | null;
|
|
124
|
+
readonly perspectiveOrigin: readonly [number, number];
|
|
125
|
+
readonly contrast: number;
|
|
126
|
+
readonly hueRotate: number;
|
|
127
|
+
readonly blend: BlendMode;
|
|
115
128
|
readonly transform: Transform;
|
|
116
129
|
readonly text: TextStyle;
|
|
117
130
|
readonly textOverflow: "clip" | "ellipsis";
|
package/dist/style/parse.js
CHANGED
|
@@ -128,11 +128,9 @@ export function parseShadows(input) {
|
|
|
128
128
|
return cached;
|
|
129
129
|
const list = input.trim() === "none" ? [] : splitTop(input).map((one) => {
|
|
130
130
|
const tokens = splitTop(one, " ");
|
|
131
|
-
if (tokens.includes("inset"))
|
|
132
|
-
throw new StyleError("Inset shadows are not supported.");
|
|
133
131
|
const colorToken = tokens.find((t) => isColor(t));
|
|
134
|
-
const lengths = tokens.filter((t) => t !== colorToken).map(parseLength);
|
|
135
|
-
return { x: lengths[0] ?? 0, y: lengths[1] ?? 0, blur: lengths[2] ?? 0, spread: lengths[3] ?? 0, color: colorToken ? parseColor(colorToken) : { r: 0, g: 0, b: 0, a: 0.3 } };
|
|
132
|
+
const lengths = tokens.filter((t) => t !== colorToken && t !== "inset").map(parseLength);
|
|
133
|
+
return { x: lengths[0] ?? 0, y: lengths[1] ?? 0, blur: lengths[2] ?? 0, spread: lengths[3] ?? 0, color: colorToken ? parseColor(colorToken) : { r: 0, g: 0, b: 0, a: 0.3 }, inset: tokens.includes("inset") };
|
|
136
134
|
});
|
|
137
135
|
shadows.set(input, list);
|
|
138
136
|
return list;
|
package/dist/style/resolve.js
CHANGED
|
@@ -35,6 +35,22 @@ function pick(r, key, options) {
|
|
|
35
35
|
throw new StyleError(`${key}: expected one of ${options.join(", ")}, got ${JSON.stringify(v)}.`);
|
|
36
36
|
return found;
|
|
37
37
|
}
|
|
38
|
+
/** A pair of finite numbers, or undefined when the property is absent. */
|
|
39
|
+
function pair(r, key) {
|
|
40
|
+
const v = r[key];
|
|
41
|
+
if (v === undefined)
|
|
42
|
+
return undefined;
|
|
43
|
+
if (Array.isArray(v) && v.length === 2 && typeof v[0] === "number" && Number.isFinite(v[0]) && typeof v[1] === "number" && Number.isFinite(v[1]))
|
|
44
|
+
return [v[0], v[1]];
|
|
45
|
+
throw new StyleError(`${key}: expected [x, y] as fractions of the box, like [0.5, 0.5], got ${JSON.stringify(v)}.`);
|
|
46
|
+
}
|
|
47
|
+
function perspectiveOf(v) {
|
|
48
|
+
if (v === undefined)
|
|
49
|
+
return null;
|
|
50
|
+
if (!(v > 0))
|
|
51
|
+
throw new StyleError(`perspective: expected a camera distance above 0, got ${v}.`);
|
|
52
|
+
return v;
|
|
53
|
+
}
|
|
38
54
|
const numOrString = (r, key) => {
|
|
39
55
|
const v = r[key];
|
|
40
56
|
if (v === undefined || typeof v === "number" || typeof v === "string")
|
|
@@ -57,6 +73,9 @@ export const rootText = {
|
|
|
57
73
|
font: null,
|
|
58
74
|
fontSize: 16,
|
|
59
75
|
fontWeight: 400,
|
|
76
|
+
italic: false,
|
|
77
|
+
axes: {},
|
|
78
|
+
shadows: [],
|
|
60
79
|
lineHeight: { kind: "multiple", v: 1.25 },
|
|
61
80
|
letterSpacing: 0,
|
|
62
81
|
textAlign: "left",
|
|
@@ -70,11 +89,18 @@ const zero = { kind: "px", v: 0 };
|
|
|
70
89
|
/** The text style an element passes to its children. */
|
|
71
90
|
export function textStyleOf(r, parent, tag) {
|
|
72
91
|
const bold = tag === "strong" || tag === "b" ? 700 : null;
|
|
92
|
+
// <em> leans when the font has an italic file to lean with, and otherwise stays as it is.
|
|
93
|
+
const emphasis = (font) => tag === "em" && font?.italic !== undefined;
|
|
73
94
|
if (!r)
|
|
74
|
-
return bold === null ? parent : { ...parent, fontWeight: bold };
|
|
95
|
+
return bold === null && !emphasis(parent.font) ? parent : { ...parent, fontWeight: bold ?? parent.fontWeight, italic: parent.italic || emphasis(parent.font) };
|
|
75
96
|
const family = r["fontFamily"];
|
|
76
97
|
if (family !== undefined && !isFontAsset(family))
|
|
77
98
|
throw new StyleError("fontFamily: expected a font asset from font().");
|
|
99
|
+
const fontStyle = pick(r, "fontStyle", ["normal", "italic"]);
|
|
100
|
+
const textShadow = text(r, "textShadow");
|
|
101
|
+
const shadows = textShadow === undefined ? parent.shadows : parseShadows(textShadow);
|
|
102
|
+
if (shadows.some((s) => s.inset))
|
|
103
|
+
throw new StyleError("textShadow: inset is for boxShadow; text shadows are always outside.");
|
|
78
104
|
const lh = r["lineHeight"];
|
|
79
105
|
let lineHeight = parent.lineHeight;
|
|
80
106
|
if (typeof lh === "number")
|
|
@@ -88,6 +114,10 @@ export function textStyleOf(r, parent, tag) {
|
|
|
88
114
|
font: family ?? parent.font,
|
|
89
115
|
fontSize: num(r, "fontSize") ?? parent.fontSize,
|
|
90
116
|
fontWeight: num(r, "fontWeight") ?? bold ?? parent.fontWeight,
|
|
117
|
+
italic: fontStyle === undefined ? parent.italic || emphasis(family ?? parent.font) : fontStyle === "italic",
|
|
118
|
+
// Axes belong to a font: a new family starts without the ones it inherited.
|
|
119
|
+
axes: axesOf(r, family === undefined || family === parent.font ? parent.axes : {}),
|
|
120
|
+
shadows,
|
|
91
121
|
lineHeight,
|
|
92
122
|
letterSpacing: num(r, "letterSpacing") ?? parent.letterSpacing,
|
|
93
123
|
textAlign: pick(r, "textAlign", ["left", "center", "right"]) ?? parent.textAlign,
|
|
@@ -95,6 +125,20 @@ export function textStyleOf(r, parent, tag) {
|
|
|
95
125
|
color: color === undefined ? parent.color : parseColor(color),
|
|
96
126
|
};
|
|
97
127
|
}
|
|
128
|
+
function axesOf(r, parent) {
|
|
129
|
+
const v = r["fontAxes"];
|
|
130
|
+
if (v === undefined)
|
|
131
|
+
return parent;
|
|
132
|
+
if (!isRecord(v))
|
|
133
|
+
throw new StyleError(`fontAxes: expected axis tags and numbers, like { SOFT: 100 }, got ${JSON.stringify(v)}.`);
|
|
134
|
+
const out = { ...parent };
|
|
135
|
+
for (const [tag, value] of Object.entries(v)) {
|
|
136
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
137
|
+
throw new StyleError(`fontAxes: ${tag} must be a number, got ${JSON.stringify(value)}.`);
|
|
138
|
+
out[tag] = value;
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
98
142
|
function sides(r) {
|
|
99
143
|
const all = text(r, "border");
|
|
100
144
|
const base = all === undefined ? noSide : parseBorder(all);
|
|
@@ -193,7 +237,12 @@ export function computeStyle(r, parentText, tag) {
|
|
|
193
237
|
blur: num(r, "blur") ?? 0,
|
|
194
238
|
brightness: num(r, "brightness") ?? 1,
|
|
195
239
|
saturate: num(r, "saturate") ?? 1,
|
|
196
|
-
|
|
240
|
+
perspective: perspectiveOf(num(r, "perspective")),
|
|
241
|
+
perspectiveOrigin: pair(r, "perspectiveOrigin") ?? [0.5, 0.5],
|
|
242
|
+
contrast: num(r, "contrast") ?? 1,
|
|
243
|
+
hueRotate: num(r, "hueRotate") ?? 0,
|
|
244
|
+
blend: pick(r, "blend", ["normal", "multiply", "screen", "overlay", "soft-light", "color-dodge"]) ?? "normal",
|
|
245
|
+
transform: { x: num(r, "x") ?? 0, y: num(r, "y") ?? 0, scale: num(r, "scale") ?? 1, rotate: num(r, "rotate") ?? 0, turn: num(r, "turn") ?? 0, tilt: num(r, "tilt") ?? 0, origin: pair(r, "transformOrigin") ?? [0.5, 0.5] },
|
|
197
246
|
text: textStyle,
|
|
198
247
|
textOverflow: pick(r, "textOverflow", ["clip", "ellipsis"]) ?? "clip",
|
|
199
248
|
objectFit: pick(r, "objectFit", ["cover", "contain", "fill"]) ?? "fill",
|
|
@@ -215,8 +264,8 @@ export function defaults(textStyle, display) {
|
|
|
215
264
|
columns: [], columnSpan: 1,
|
|
216
265
|
overflowHidden: false, zIndex: 0,
|
|
217
266
|
background: [], border: [noSide, noSide, noSide, noSide], radius: [zero, zero, zero, zero], shadows: [],
|
|
218
|
-
opacity: 1, blur: 0, brightness: 1, saturate: 1,
|
|
219
|
-
transform: { x: 0, y: 0, scale: 1, rotate: 0, turn: 0, tilt: 0 },
|
|
267
|
+
opacity: 1, blur: 0, brightness: 1, saturate: 1, perspective: null, perspectiveOrigin: [0.5, 0.5], contrast: 1, hueRotate: 0, blend: "normal",
|
|
268
|
+
transform: { x: 0, y: 0, scale: 1, rotate: 0, turn: 0, tilt: 0, origin: [0.5, 0.5] },
|
|
220
269
|
text: textStyle, textOverflow: "clip", objectFit: "fill", objectPosition: [0.5, 0.5],
|
|
221
270
|
};
|
|
222
271
|
}
|
package/dist/text/fonts.d.ts
CHANGED
|
@@ -3,6 +3,9 @@ import type { FontFile, FontInstance, PathVerb } from "./font-types.js";
|
|
|
3
3
|
export declare class FontMissing extends Error {
|
|
4
4
|
readonly name = "FontMissing";
|
|
5
5
|
}
|
|
6
|
+
export declare class FontAxisError extends Error {
|
|
7
|
+
readonly name = "FontAxisError";
|
|
8
|
+
}
|
|
6
9
|
export interface ResolvedFont {
|
|
7
10
|
/** Small integer used in display lists; stable for the life of this library. */
|
|
8
11
|
readonly id: number;
|
|
@@ -18,7 +21,7 @@ export declare class FontLibrary {
|
|
|
18
21
|
private readonly byId;
|
|
19
22
|
add(asset: FontAsset, file: FontFile): void;
|
|
20
23
|
has(asset: FontAsset): boolean;
|
|
21
|
-
resolve(asset: FontAsset, weight: number, size: number): ResolvedFont;
|
|
24
|
+
resolve(asset: FontAsset, weight: number, size: number, axes?: Readonly<Record<string, number>>): ResolvedFont;
|
|
22
25
|
instanceById(id: number): FontInstance | undefined;
|
|
23
26
|
}
|
|
24
27
|
/** A glyph outline in em units (what the rasterizer's define_glyph expects). */
|
package/dist/text/fonts.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export class FontMissing extends Error {
|
|
2
2
|
name = "FontMissing";
|
|
3
3
|
}
|
|
4
|
+
export class FontAxisError extends Error {
|
|
5
|
+
name = "FontAxisError";
|
|
6
|
+
}
|
|
4
7
|
/** Loaded font files and the instances the film asks for. Optical size follows the font size
|
|
5
8
|
* (like CSS `font-optical-sizing: auto`), so a 150px headline gets the display cut. */
|
|
6
9
|
export class FontLibrary {
|
|
@@ -13,7 +16,7 @@ export class FontLibrary {
|
|
|
13
16
|
has(asset) {
|
|
14
17
|
return this.files.has(asset.name);
|
|
15
18
|
}
|
|
16
|
-
resolve(asset, weight, size) {
|
|
19
|
+
resolve(asset, weight, size, axes = {}) {
|
|
17
20
|
const file = this.files.get(asset.name);
|
|
18
21
|
if (!file)
|
|
19
22
|
throw new FontMissing(`Font "${asset.name}" is not loaded (was it listed in the film's assets?).`);
|
|
@@ -25,6 +28,11 @@ export class FontLibrary {
|
|
|
25
28
|
if (axis.tag === "opsz")
|
|
26
29
|
settings["opsz"] = Math.round(Math.min(axis.max, Math.max(axis.min, size)));
|
|
27
30
|
}
|
|
31
|
+
for (const [tag, value] of Object.entries(axes)) {
|
|
32
|
+
if (!file.axes.some((a) => a.tag === tag))
|
|
33
|
+
throw new FontAxisError(`fontAxes: font "${asset.name}" has no axis "${tag}"${file.axes.length > 0 ? `; it has ${file.axes.map((a) => a.tag).join(", ")}` : ": it isn't a variable font"}.`);
|
|
34
|
+
settings[tag] = value;
|
|
35
|
+
}
|
|
28
36
|
const instance = file.instance(settings);
|
|
29
37
|
let id = this.ids.get(instance.key);
|
|
30
38
|
if (id === undefined) {
|
package/dist/text/ttf.js
CHANGED
|
@@ -2,7 +2,11 @@ import { Bin, FontError, readTables } from "./sfnt.js";
|
|
|
2
2
|
import { Gvar, Hvar, ItemVariationStore, normalize, readAvar, readFvar } from "./variation.js";
|
|
3
3
|
const empty = { kind: "empty" };
|
|
4
4
|
const maxDepth = 8;
|
|
5
|
+
/** Each parsed file's own number. Two files of one family (an upright and an italic) share a name
|
|
6
|
+
* and axis values, so that can't tell their instances apart: their glyph ids mean different shapes. */
|
|
7
|
+
let filesParsed = 0;
|
|
5
8
|
export function parseFont(bytes) {
|
|
9
|
+
const file = ++filesParsed;
|
|
6
10
|
const records = readTables(bytes);
|
|
7
11
|
const table = (tag) => {
|
|
8
12
|
const r = records.get(tag);
|
|
@@ -67,7 +71,7 @@ export function parseFont(bytes) {
|
|
|
67
71
|
const instance = (settings) => {
|
|
68
72
|
const values = axes.map((axis) => clamp(settings[axis.tag] ?? axis.defaultValue, axis.min, axis.max));
|
|
69
73
|
const parts = axes.map((axis, i) => `${axis.tag}=${formatValue(values[i] ?? axis.defaultValue)}`).sort();
|
|
70
|
-
const key = parts.length > 0 ? `${familyName} ${parts.join(" ")}` : familyName
|
|
74
|
+
const key = parts.length > 0 ? `${file}:${familyName} ${parts.join(" ")}` : `${file}:${familyName}`;
|
|
71
75
|
const cached = instances.get(key);
|
|
72
76
|
if (cached)
|
|
73
77
|
return cached;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tongue-core/engine",
|
|
3
|
-
"version": "0.1.0-rc.
|
|
3
|
+
"version": "0.1.0-rc.3",
|
|
4
4
|
"description": "Frame to pixels for Tongue films: evaluate, layout, paint and rasterize, deterministic everywhere.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"README.md"
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@tongue-core/film": "0.1.0-rc.
|
|
29
|
+
"@tongue-core/film": "0.1.0-rc.3"
|
|
30
30
|
},
|
|
31
31
|
"publishConfig": {
|
|
32
32
|
"access": "public"
|
package/raster.wasm
CHANGED
|
Binary file
|