sci2-ts 0.13.0 → 0.15.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 +12 -0
- package/lib/991.sc +44 -30
- package/lib/993.sc +40 -6
- package/lib/996.sc +8 -3
- package/lib/997.sc +48 -14
- package/package.json +1 -1
- package/packages/content/src/room.js +46 -3
- package/packages/content/src/room.ts +41 -3
- package/packages/content/src/target.d.ts +9 -0
- package/packages/content/src/target.ts +5 -0
- package/packages/sci/src/index.d.ts +1 -0
- package/packages/sci/src/index.js +1 -0
- package/packages/sci/src/index.ts +1 -0
- package/packages/sci/src/text/font.d.ts +19 -2
- package/packages/sci/src/text/font.js +62 -14
- package/packages/sci/src/text/font.ts +64 -15
- package/packages/sci/src/text/styled.d.ts +40 -0
- package/packages/sci/src/text/styled.js +92 -0
- package/packages/sci/src/text/styled.ts +110 -0
- package/packages/sci/src/vm/kernels/text.js +40 -29
- package/packages/sci/src/vm/kernels/text.ts +41 -28
- package/tools/art/build.d.ts +9 -3
- package/tools/art/build.js +33 -2
- package/tools/art/build.ts +27 -4
- package/tools/art/font.d.ts +20 -0
- package/tools/art/font.js +35 -0
- package/tools/art/font.ts +41 -0
- package/tools/game/build.d.ts +3 -1
- package/tools/game/build.js +13 -1
- package/tools/game/build.ts +17 -3
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/** How far the pen moves on after a glyph. */
|
|
2
|
+
export const advanceOf = (g) => g.advance ?? g.width;
|
|
3
|
+
/** The extension at the end of a font resource with bearings and advances: see writeFont. */
|
|
4
|
+
const METRICS_TAG = [0x58, 0x4d, 0x54, 0x31]; // "XMT1"
|
|
5
|
+
const hasOwnMetrics = (g) => !!g && ((g.bearingX ?? 0) !== 0 || (g.advance ?? g.width) !== g.width);
|
|
1
6
|
/**
|
|
2
7
|
* SCI bitmap font (unchanged since SCI0):
|
|
3
8
|
*
|
|
@@ -8,6 +13,11 @@
|
|
|
8
13
|
*
|
|
9
14
|
* Each glyph: u8 width, u8 height, then rows of 1bpp pixels, MSB first,
|
|
10
15
|
* each row padded to a whole byte.
|
|
16
|
+
*
|
|
17
|
+
* A font can also carry each glyph's bearing and advance (writeFont adds them when any glyph
|
|
18
|
+
* has its own): after the glyphs, i8 bearing and u8 advance per character, then "XMT1". SCI
|
|
19
|
+
* reads glyphs by their offsets and never gets there; without it, bearings are 0 and
|
|
20
|
+
* advances the widths, as fonts always were.
|
|
11
21
|
*/
|
|
12
22
|
export function parseFont(data) {
|
|
13
23
|
const v = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
@@ -28,6 +38,19 @@ export function parseFont(data) {
|
|
|
28
38
|
}
|
|
29
39
|
glyphs[c] = { width, height: h, pixels };
|
|
30
40
|
}
|
|
41
|
+
const tagAt = data.length - 4;
|
|
42
|
+
if (tagAt - count * 2 >= 6 + count * 2 && METRICS_TAG.every((b, i) => data[tagAt + i] === b)) {
|
|
43
|
+
const at = tagAt - count * 2;
|
|
44
|
+
glyphs.forEach((g, c) => {
|
|
45
|
+
if (!g)
|
|
46
|
+
return;
|
|
47
|
+
const bearing = (data[at + c * 2] << 24) >> 24, advance = data[at + c * 2 + 1];
|
|
48
|
+
if (bearing)
|
|
49
|
+
g.bearingX = bearing;
|
|
50
|
+
if (advance !== g.width)
|
|
51
|
+
g.advance = advance;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
31
54
|
return { height, glyphs };
|
|
32
55
|
}
|
|
33
56
|
/** A font resource (the inverse of parseFont). Missing glyphs are written as 0×0. */
|
|
@@ -48,7 +71,9 @@ export function writeFont(font) {
|
|
|
48
71
|
}
|
|
49
72
|
return out;
|
|
50
73
|
});
|
|
51
|
-
const
|
|
74
|
+
const metrics = font.glyphs.some(hasOwnMetrics);
|
|
75
|
+
const bodySize = bodies.reduce((n, b) => n + b.length, 0);
|
|
76
|
+
const out = new Uint8Array(6 + count * 2 + bodySize + (metrics ? count * 2 + 4 : 0));
|
|
52
77
|
const v = new DataView(out.buffer);
|
|
53
78
|
v.setUint16(2, count, true);
|
|
54
79
|
v.setUint16(4, font.height, true);
|
|
@@ -60,9 +85,19 @@ export function writeFont(font) {
|
|
|
60
85
|
out.set(b, at);
|
|
61
86
|
at += b.length;
|
|
62
87
|
});
|
|
88
|
+
if (metrics) {
|
|
89
|
+
font.glyphs.forEach((g, c) => {
|
|
90
|
+
const bearing = g?.bearingX ?? 0, advance = g ? advanceOf(g) : 0;
|
|
91
|
+
if (bearing < -128 || bearing > 127 || advance < 0 || advance > 255)
|
|
92
|
+
throw new Error(`glyph ${c}: bearing ${bearing}, advance ${advance}`);
|
|
93
|
+
out[at + c * 2] = bearing & 0xff;
|
|
94
|
+
out[at + c * 2 + 1] = advance;
|
|
95
|
+
});
|
|
96
|
+
out.set(METRICS_TAG, at + count * 2);
|
|
97
|
+
}
|
|
63
98
|
return out;
|
|
64
99
|
}
|
|
65
|
-
export const textWidth = (font, text) => [...text].reduce((w, ch) =>
|
|
100
|
+
export const textWidth = (font, text) => [...text].reduce((w, ch) => { const g = font.glyphs[ch.charCodeAt(0)]; return w + (g ? advanceOf(g) : 0); }, 0);
|
|
66
101
|
/** Splits text into lines no wider than `maxWidth`, breaking at spaces and honouring CR/LF. */
|
|
67
102
|
export function wrapText(font, text, maxWidth) {
|
|
68
103
|
const lines = [];
|
|
@@ -79,25 +114,38 @@ export function wrapText(font, text, maxWidth) {
|
|
|
79
114
|
}
|
|
80
115
|
return lines;
|
|
81
116
|
}
|
|
82
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Renders already-wrapped lines, each aligned within the line width. Ink that starts left of
|
|
119
|
+
* the pen or runs past the last advance (an italic's first and last letters) is kept: the
|
|
120
|
+
* bitmap grows to hold it, and `originX` says where the pen started.
|
|
121
|
+
*/
|
|
83
122
|
export function renderText(font, lines, align = "left") {
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
let x = align === "center" ? Math.floor((width - textWidth(font, line)) / 2) : 0;
|
|
89
|
-
for (const ch of line) {
|
|
123
|
+
const lineWidth = Math.max(1, ...lines.map((l) => textWidth(font, l)));
|
|
124
|
+
const pens = lines.map((line) => {
|
|
125
|
+
let x = align === "center" ? Math.floor((lineWidth - textWidth(font, line)) / 2) : 0;
|
|
126
|
+
return [...line].flatMap((ch) => {
|
|
90
127
|
const g = font.glyphs[ch.charCodeAt(0)];
|
|
91
128
|
if (!g)
|
|
92
|
-
|
|
93
|
-
|
|
129
|
+
return [];
|
|
130
|
+
const at = { g, x: x + (g.bearingX ?? 0) };
|
|
131
|
+
x += advanceOf(g);
|
|
132
|
+
return [at];
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
const inks = pens.flat();
|
|
136
|
+
const left = Math.min(0, ...inks.map((p) => p.x)), right = Math.max(lineWidth, ...inks.map((p) => p.x + p.g.width));
|
|
137
|
+
const width = right - left;
|
|
138
|
+
const height = Math.max(1, lines.length * font.height);
|
|
139
|
+
const pixels = new Uint8Array(width * height);
|
|
140
|
+
pens.forEach((row, r) => {
|
|
141
|
+
for (const { g, x } of row) {
|
|
142
|
+
for (let gy = 0; gy < g.height && r * font.height + gy < height; gy++) {
|
|
94
143
|
for (let gx = 0; gx < g.width; gx++) {
|
|
95
144
|
if (g.pixels[gy * g.width + gx])
|
|
96
|
-
pixels[(
|
|
145
|
+
pixels[(r * font.height + gy) * width + x - left + gx] = 1;
|
|
97
146
|
}
|
|
98
147
|
}
|
|
99
|
-
x += g.width;
|
|
100
148
|
}
|
|
101
149
|
});
|
|
102
|
-
return { width, height, pixels };
|
|
150
|
+
return { width, height, pixels, originX: -left };
|
|
103
151
|
}
|
|
@@ -3,8 +3,19 @@ export interface Glyph {
|
|
|
3
3
|
height: number;
|
|
4
4
|
/** 1 byte per pixel, 1 = ink. */
|
|
5
5
|
pixels: Uint8Array;
|
|
6
|
+
/** Where the bitmap starts from the pen (default 0); it can be left of it, an italic j's tail. */
|
|
7
|
+
bearingX?: number;
|
|
8
|
+
/** How far the pen moves on (default the width); ink can reach past it. */
|
|
9
|
+
advance?: number;
|
|
6
10
|
}
|
|
7
11
|
|
|
12
|
+
/** How far the pen moves on after a glyph. */
|
|
13
|
+
export const advanceOf = (g: Glyph) => g.advance ?? g.width;
|
|
14
|
+
|
|
15
|
+
/** The extension at the end of a font resource with bearings and advances: see writeFont. */
|
|
16
|
+
const METRICS_TAG = [0x58, 0x4d, 0x54, 0x31]; // "XMT1"
|
|
17
|
+
const hasOwnMetrics = (g: Glyph | undefined) => !!g && ((g.bearingX ?? 0) !== 0 || (g.advance ?? g.width) !== g.width);
|
|
18
|
+
|
|
8
19
|
export interface Font {
|
|
9
20
|
/** Line height in pixels. */
|
|
10
21
|
height: number;
|
|
@@ -22,6 +33,11 @@ export interface Font {
|
|
|
22
33
|
*
|
|
23
34
|
* Each glyph: u8 width, u8 height, then rows of 1bpp pixels, MSB first,
|
|
24
35
|
* each row padded to a whole byte.
|
|
36
|
+
*
|
|
37
|
+
* A font can also carry each glyph's bearing and advance (writeFont adds them when any glyph
|
|
38
|
+
* has its own): after the glyphs, i8 bearing and u8 advance per character, then "XMT1". SCI
|
|
39
|
+
* reads glyphs by their offsets and never gets there; without it, bearings are 0 and
|
|
40
|
+
* advances the widths, as fonts always were.
|
|
25
41
|
*/
|
|
26
42
|
export function parseFont(data: Uint8Array): Font {
|
|
27
43
|
const v = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
@@ -43,6 +59,16 @@ export function parseFont(data: Uint8Array): Font {
|
|
|
43
59
|
}
|
|
44
60
|
glyphs[c] = { width, height: h, pixels };
|
|
45
61
|
}
|
|
62
|
+
const tagAt = data.length - 4;
|
|
63
|
+
if (tagAt - count * 2 >= 6 + count * 2 && METRICS_TAG.every((b, i) => data[tagAt + i] === b)) {
|
|
64
|
+
const at = tagAt - count * 2;
|
|
65
|
+
glyphs.forEach((g, c) => {
|
|
66
|
+
if (!g) return;
|
|
67
|
+
const bearing = (data[at + c * 2]! << 24) >> 24, advance = data[at + c * 2 + 1]!;
|
|
68
|
+
if (bearing) g.bearingX = bearing;
|
|
69
|
+
if (advance !== g.width) g.advance = advance;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
46
72
|
return { height, glyphs };
|
|
47
73
|
}
|
|
48
74
|
|
|
@@ -61,7 +87,9 @@ export function writeFont(font: Font): Uint8Array {
|
|
|
61
87
|
}
|
|
62
88
|
return out;
|
|
63
89
|
});
|
|
64
|
-
const
|
|
90
|
+
const metrics = font.glyphs.some(hasOwnMetrics);
|
|
91
|
+
const bodySize = bodies.reduce((n, b) => n + b.length, 0);
|
|
92
|
+
const out = new Uint8Array(6 + count * 2 + bodySize + (metrics ? count * 2 + 4 : 0));
|
|
65
93
|
const v = new DataView(out.buffer);
|
|
66
94
|
v.setUint16(2, count, true);
|
|
67
95
|
v.setUint16(4, font.height, true);
|
|
@@ -72,11 +100,20 @@ export function writeFont(font: Font): Uint8Array {
|
|
|
72
100
|
out.set(b, at);
|
|
73
101
|
at += b.length;
|
|
74
102
|
});
|
|
103
|
+
if (metrics) {
|
|
104
|
+
font.glyphs.forEach((g, c) => {
|
|
105
|
+
const bearing = g?.bearingX ?? 0, advance = g ? advanceOf(g) : 0;
|
|
106
|
+
if (bearing < -128 || bearing > 127 || advance < 0 || advance > 255) throw new Error(`glyph ${c}: bearing ${bearing}, advance ${advance}`);
|
|
107
|
+
out[at + c * 2] = bearing & 0xff;
|
|
108
|
+
out[at + c * 2 + 1] = advance;
|
|
109
|
+
});
|
|
110
|
+
out.set(METRICS_TAG, at + count * 2);
|
|
111
|
+
}
|
|
75
112
|
return out;
|
|
76
113
|
}
|
|
77
114
|
|
|
78
115
|
export const textWidth = (font: Font, text: string): number =>
|
|
79
|
-
[...text].reduce((w, ch) =>
|
|
116
|
+
[...text].reduce((w, ch) => { const g = font.glyphs[ch.charCodeAt(0)]; return w + (g ? advanceOf(g) : 0); }, 0);
|
|
80
117
|
|
|
81
118
|
/** Splits text into lines no wider than `maxWidth`, breaking at spaces and honouring CR/LF. */
|
|
82
119
|
export function wrapText(font: Font, text: string, maxWidth: number): string[] {
|
|
@@ -102,24 +139,36 @@ export interface TextBitmap {
|
|
|
102
139
|
pixels: Uint8Array;
|
|
103
140
|
}
|
|
104
141
|
|
|
105
|
-
/**
|
|
106
|
-
|
|
107
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Renders already-wrapped lines, each aligned within the line width. Ink that starts left of
|
|
144
|
+
* the pen or runs past the last advance (an italic's first and last letters) is kept: the
|
|
145
|
+
* bitmap grows to hold it, and `originX` says where the pen started.
|
|
146
|
+
*/
|
|
147
|
+
export function renderText(font: Font, lines: string[], align: "left" | "center" = "left"): TextBitmap & { originX: number } {
|
|
148
|
+
const lineWidth = Math.max(1, ...lines.map((l) => textWidth(font, l)));
|
|
149
|
+
const pens = lines.map((line) => {
|
|
150
|
+
let x = align === "center" ? Math.floor((lineWidth - textWidth(font, line)) / 2) : 0;
|
|
151
|
+
return [...line].flatMap((ch) => {
|
|
152
|
+
const g = font.glyphs[ch.charCodeAt(0)];
|
|
153
|
+
if (!g) return [];
|
|
154
|
+
const at = { g, x: x + (g.bearingX ?? 0) };
|
|
155
|
+
x += advanceOf(g);
|
|
156
|
+
return [at];
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
const inks = pens.flat();
|
|
160
|
+
const left = Math.min(0, ...inks.map((p) => p.x)), right = Math.max(lineWidth, ...inks.map((p) => p.x + p.g.width));
|
|
161
|
+
const width = right - left;
|
|
108
162
|
const height = Math.max(1, lines.length * font.height);
|
|
109
163
|
const pixels = new Uint8Array(width * height);
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
for (const ch of line) {
|
|
114
|
-
const g = font.glyphs[ch.charCodeAt(0)];
|
|
115
|
-
if (!g) continue;
|
|
116
|
-
for (let gy = 0; gy < g.height; gy++) {
|
|
164
|
+
pens.forEach((row, r) => {
|
|
165
|
+
for (const { g, x } of row) {
|
|
166
|
+
for (let gy = 0; gy < g.height && r * font.height + gy < height; gy++) {
|
|
117
167
|
for (let gx = 0; gx < g.width; gx++) {
|
|
118
|
-
if (g.pixels[gy * g.width + gx]) pixels[(
|
|
168
|
+
if (g.pixels[gy * g.width + gx]) pixels[(r * font.height + gy) * width + x - left + gx] = 1;
|
|
119
169
|
}
|
|
120
170
|
}
|
|
121
|
-
x += g.width;
|
|
122
171
|
}
|
|
123
172
|
});
|
|
124
|
-
return { width, height, pixels };
|
|
173
|
+
return { width, height, pixels, originX: -left };
|
|
125
174
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type Font, type Glyph } from "./font.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Text with styles in it, as SCI32 writes them: `|f3|` switches to font 3 and `|f|` back to
|
|
4
|
+
* the text's own; `|c5|` and `|c|` do the same for the colour. A word whose style changes
|
|
5
|
+
* part-way through is still one word, and the codes take no room.
|
|
6
|
+
*/
|
|
7
|
+
export interface StyledChar {
|
|
8
|
+
ch: string;
|
|
9
|
+
font: number;
|
|
10
|
+
/** A colour of its own, or undefined for the text's. */
|
|
11
|
+
color?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function parseStyled(text: string, font: number): StyledChar[];
|
|
14
|
+
/** The text without its codes. */
|
|
15
|
+
export declare const plainOf: (text: string) => string;
|
|
16
|
+
type FontOf = (n: number) => Font;
|
|
17
|
+
/** The pen's travel over some characters: the sum of their advances. */
|
|
18
|
+
export declare function styledWidth(fontOf: FontOf, chars: StyledChar[]): number;
|
|
19
|
+
export interface StyledLine {
|
|
20
|
+
chars: StyledChar[];
|
|
21
|
+
/** The pen's travel: what wrapping and alignment go by. */
|
|
22
|
+
width: number;
|
|
23
|
+
/** The tallest font in it (the text's own if it's empty). */
|
|
24
|
+
height: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Lines no wider than maxWidth, breaking after spaces and at CR/LF, each character measured
|
|
28
|
+
* in its own font. A word too long for a line gets a line of its own.
|
|
29
|
+
*/
|
|
30
|
+
export declare function wrapStyled(fontOf: FontOf, chars: StyledChar[], font: number, maxWidth: number): StyledLine[];
|
|
31
|
+
/**
|
|
32
|
+
* Where each character's ink goes: the pen starts at x (moved by alignment), and each glyph
|
|
33
|
+
* is drawn at the pen plus its bearing, then the pen moves on by its advance.
|
|
34
|
+
*/
|
|
35
|
+
export declare function placeLine(fontOf: FontOf, line: StyledLine, x: number): {
|
|
36
|
+
glyph: Glyph;
|
|
37
|
+
x: number;
|
|
38
|
+
color?: number;
|
|
39
|
+
}[];
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { advanceOf } from "./font.js";
|
|
2
|
+
const CODE = /\|([fc])(\d*)\|/y;
|
|
3
|
+
export function parseStyled(text, font) {
|
|
4
|
+
const out = [];
|
|
5
|
+
let f = font, color;
|
|
6
|
+
for (let i = 0; i < text.length;) {
|
|
7
|
+
CODE.lastIndex = i;
|
|
8
|
+
const m = text[i] === "|" ? CODE.exec(text) : null;
|
|
9
|
+
if (m) {
|
|
10
|
+
if (m[1] === "f")
|
|
11
|
+
f = m[2] ? Number(m[2]) : font;
|
|
12
|
+
else
|
|
13
|
+
color = m[2] ? Number(m[2]) : undefined;
|
|
14
|
+
i = CODE.lastIndex;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
out.push({ ch: text[i], font: f, ...(color === undefined ? {} : { color }) });
|
|
18
|
+
i++;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
/** The text without its codes. */
|
|
23
|
+
export const plainOf = (text) => text.replace(/\|[fc]\d*\|/g, "");
|
|
24
|
+
const glyphOf = (fontOf, c) => fontOf(c.font).glyphs[c.ch.charCodeAt(0)];
|
|
25
|
+
/** The pen's travel over some characters: the sum of their advances. */
|
|
26
|
+
export function styledWidth(fontOf, chars) {
|
|
27
|
+
return chars.reduce((w, c) => { const g = glyphOf(fontOf, c); return w + (g ? advanceOf(g) : 0); }, 0);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Lines no wider than maxWidth, breaking after spaces and at CR/LF, each character measured
|
|
31
|
+
* in its own font. A word too long for a line gets a line of its own.
|
|
32
|
+
*/
|
|
33
|
+
export function wrapStyled(fontOf, chars, font, maxWidth) {
|
|
34
|
+
const lines = [];
|
|
35
|
+
const finish = (line) => {
|
|
36
|
+
while (line.length && line[line.length - 1].ch === " ")
|
|
37
|
+
line.pop();
|
|
38
|
+
lines.push({ chars: line, width: styledWidth(fontOf, line), height: Math.max(fontOf(font).height, ...line.map((c) => fontOf(c.font).height)) });
|
|
39
|
+
};
|
|
40
|
+
// Paragraphs, then words: a word is its letters and the spaces after them.
|
|
41
|
+
const paragraphs = [[]];
|
|
42
|
+
for (let i = 0; i < chars.length; i++) {
|
|
43
|
+
const c = chars[i];
|
|
44
|
+
if (c.ch === "\r" || c.ch === "\n") {
|
|
45
|
+
if (c.ch === "\r" && chars[i + 1]?.ch === "\n")
|
|
46
|
+
i++;
|
|
47
|
+
paragraphs.push([]);
|
|
48
|
+
}
|
|
49
|
+
else
|
|
50
|
+
paragraphs[paragraphs.length - 1].push(c);
|
|
51
|
+
}
|
|
52
|
+
for (const paragraph of paragraphs) {
|
|
53
|
+
const words = [];
|
|
54
|
+
let word = [];
|
|
55
|
+
paragraph.forEach((c, i) => {
|
|
56
|
+
word.push(c);
|
|
57
|
+
if (c.ch === " " && paragraph[i + 1]?.ch !== " ")
|
|
58
|
+
(words.push(word), (word = []));
|
|
59
|
+
});
|
|
60
|
+
if (word.length)
|
|
61
|
+
words.push(word);
|
|
62
|
+
let line = [];
|
|
63
|
+
for (const w of words) {
|
|
64
|
+
const trimmed = [...line, ...w];
|
|
65
|
+
while (trimmed.length && trimmed[trimmed.length - 1].ch === " ")
|
|
66
|
+
trimmed.pop();
|
|
67
|
+
if (line.length && styledWidth(fontOf, trimmed) > maxWidth) {
|
|
68
|
+
finish(line);
|
|
69
|
+
line = [];
|
|
70
|
+
}
|
|
71
|
+
line.push(...w);
|
|
72
|
+
}
|
|
73
|
+
finish(line);
|
|
74
|
+
}
|
|
75
|
+
return lines;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Where each character's ink goes: the pen starts at x (moved by alignment), and each glyph
|
|
79
|
+
* is drawn at the pen plus its bearing, then the pen moves on by its advance.
|
|
80
|
+
*/
|
|
81
|
+
export function placeLine(fontOf, line, x) {
|
|
82
|
+
const out = [];
|
|
83
|
+
let pen = x;
|
|
84
|
+
for (const c of line.chars) {
|
|
85
|
+
const glyph = glyphOf(fontOf, c);
|
|
86
|
+
if (!glyph)
|
|
87
|
+
continue;
|
|
88
|
+
out.push({ glyph, x: pen + (glyph.bearingX ?? 0), ...(c.color === undefined ? {} : { color: c.color }) });
|
|
89
|
+
pen += advanceOf(glyph);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { advanceOf, type Font, type Glyph } from "./font.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Text with styles in it, as SCI32 writes them: `|f3|` switches to font 3 and `|f|` back to
|
|
5
|
+
* the text's own; `|c5|` and `|c|` do the same for the colour. A word whose style changes
|
|
6
|
+
* part-way through is still one word, and the codes take no room.
|
|
7
|
+
*/
|
|
8
|
+
export interface StyledChar {
|
|
9
|
+
ch: string;
|
|
10
|
+
font: number;
|
|
11
|
+
/** A colour of its own, or undefined for the text's. */
|
|
12
|
+
color?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const CODE = /\|([fc])(\d*)\|/y;
|
|
16
|
+
|
|
17
|
+
export function parseStyled(text: string, font: number): StyledChar[] {
|
|
18
|
+
const out: StyledChar[] = [];
|
|
19
|
+
let f = font, color: number | undefined;
|
|
20
|
+
for (let i = 0; i < text.length; ) {
|
|
21
|
+
CODE.lastIndex = i;
|
|
22
|
+
const m = text[i] === "|" ? CODE.exec(text) : null;
|
|
23
|
+
if (m) {
|
|
24
|
+
if (m[1] === "f") f = m[2] ? Number(m[2]) : font;
|
|
25
|
+
else color = m[2] ? Number(m[2]) : undefined;
|
|
26
|
+
i = CODE.lastIndex;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
out.push({ ch: text[i]!, font: f, ...(color === undefined ? {} : { color }) });
|
|
30
|
+
i++;
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The text without its codes. */
|
|
36
|
+
export const plainOf = (text: string) => text.replace(/\|[fc]\d*\|/g, "");
|
|
37
|
+
|
|
38
|
+
type FontOf = (n: number) => Font;
|
|
39
|
+
const glyphOf = (fontOf: FontOf, c: StyledChar): Glyph | undefined => fontOf(c.font).glyphs[c.ch.charCodeAt(0)];
|
|
40
|
+
|
|
41
|
+
/** The pen's travel over some characters: the sum of their advances. */
|
|
42
|
+
export function styledWidth(fontOf: FontOf, chars: StyledChar[]): number {
|
|
43
|
+
return chars.reduce((w, c) => { const g = glyphOf(fontOf, c); return w + (g ? advanceOf(g) : 0); }, 0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface StyledLine {
|
|
47
|
+
chars: StyledChar[];
|
|
48
|
+
/** The pen's travel: what wrapping and alignment go by. */
|
|
49
|
+
width: number;
|
|
50
|
+
/** The tallest font in it (the text's own if it's empty). */
|
|
51
|
+
height: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Lines no wider than maxWidth, breaking after spaces and at CR/LF, each character measured
|
|
56
|
+
* in its own font. A word too long for a line gets a line of its own.
|
|
57
|
+
*/
|
|
58
|
+
export function wrapStyled(fontOf: FontOf, chars: StyledChar[], font: number, maxWidth: number): StyledLine[] {
|
|
59
|
+
const lines: StyledLine[] = [];
|
|
60
|
+
const finish = (line: StyledChar[]) => {
|
|
61
|
+
while (line.length && line[line.length - 1]!.ch === " ") line.pop();
|
|
62
|
+
lines.push({ chars: line, width: styledWidth(fontOf, line), height: Math.max(fontOf(font).height, ...line.map((c) => fontOf(c.font).height)) });
|
|
63
|
+
};
|
|
64
|
+
// Paragraphs, then words: a word is its letters and the spaces after them.
|
|
65
|
+
const paragraphs: StyledChar[][] = [[]];
|
|
66
|
+
for (let i = 0; i < chars.length; i++) {
|
|
67
|
+
const c = chars[i]!;
|
|
68
|
+
if (c.ch === "\r" || c.ch === "\n") {
|
|
69
|
+
if (c.ch === "\r" && chars[i + 1]?.ch === "\n") i++;
|
|
70
|
+
paragraphs.push([]);
|
|
71
|
+
} else paragraphs[paragraphs.length - 1]!.push(c);
|
|
72
|
+
}
|
|
73
|
+
for (const paragraph of paragraphs) {
|
|
74
|
+
const words: StyledChar[][] = [];
|
|
75
|
+
let word: StyledChar[] = [];
|
|
76
|
+
paragraph.forEach((c, i) => {
|
|
77
|
+
word.push(c);
|
|
78
|
+
if (c.ch === " " && paragraph[i + 1]?.ch !== " ") (words.push(word), (word = []));
|
|
79
|
+
});
|
|
80
|
+
if (word.length) words.push(word);
|
|
81
|
+
let line: StyledChar[] = [];
|
|
82
|
+
for (const w of words) {
|
|
83
|
+
const trimmed = [...line, ...w];
|
|
84
|
+
while (trimmed.length && trimmed[trimmed.length - 1]!.ch === " ") trimmed.pop();
|
|
85
|
+
if (line.length && styledWidth(fontOf, trimmed) > maxWidth) {
|
|
86
|
+
finish(line);
|
|
87
|
+
line = [];
|
|
88
|
+
}
|
|
89
|
+
line.push(...w);
|
|
90
|
+
}
|
|
91
|
+
finish(line);
|
|
92
|
+
}
|
|
93
|
+
return lines;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Where each character's ink goes: the pen starts at x (moved by alignment), and each glyph
|
|
98
|
+
* is drawn at the pen plus its bearing, then the pen moves on by its advance.
|
|
99
|
+
*/
|
|
100
|
+
export function placeLine(fontOf: FontOf, line: StyledLine, x: number): { glyph: Glyph; x: number; color?: number }[] {
|
|
101
|
+
const out: { glyph: Glyph; x: number; color?: number }[] = [];
|
|
102
|
+
let pen = x;
|
|
103
|
+
for (const c of line.chars) {
|
|
104
|
+
const glyph = glyphOf(fontOf, c);
|
|
105
|
+
if (!glyph) continue;
|
|
106
|
+
out.push({ glyph, x: pen + (glyph.bearingX ?? 0), ...(c.color === undefined ? {} : { color: c.color }) });
|
|
107
|
+
pen += advanceOf(glyph);
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ResourceType } from "../../resource/types.js";
|
|
2
|
-
import { parseFont
|
|
2
|
+
import { parseFont } from "../../text/font.js";
|
|
3
|
+
import { parseStyled, placeLine, styledWidth, wrapStyled } from "../../text/styled.js";
|
|
3
4
|
import { hasRef, parseMessages } from "../../text/message.js";
|
|
4
5
|
import { NULL, fromInt, segmentOf, toSigned } from "../value.js";
|
|
5
6
|
import { stringHelpers } from "./arrays.js";
|
|
@@ -95,31 +96,38 @@ const DEFAULT_MAX_WIDTH = (320 * 3) / 5;
|
|
|
95
96
|
* arrow glyphs on dialog scroll buttons.
|
|
96
97
|
*/
|
|
97
98
|
export const unescapeText = (s) => s.replace(/\\([0-9a-fA-F]{2})/g, (_m, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
98
|
-
/**
|
|
99
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Draws wrapped text into a bitmap, inside `rect`, with alignment. The text can change font
|
|
101
|
+
* and colour (`|f3|`, `|c5|`). Ink can start left of the pen or run past it (an italic's
|
|
102
|
+
* tail) into the box's margin; only the bitmap's edges stop it.
|
|
103
|
+
*/
|
|
104
|
+
function drawText(vm, bmp, fontNo, str, rect, color, align) {
|
|
105
|
+
const fontOf = (n) => text(vm).font(n);
|
|
100
106
|
const width = rect.right - rect.left;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
for (const ch of line) {
|
|
107
|
-
const g = font.glyphs[ch.charCodeAt(0)];
|
|
108
|
-
if (!g)
|
|
109
|
-
continue;
|
|
107
|
+
let y0 = rect.top;
|
|
108
|
+
for (const line of wrapStyled(fontOf, parseStyled(str, fontNo), fontNo, width)) {
|
|
109
|
+
const x = rect.left + (align === TextAlign.Center ? Math.floor((width - line.width) / 2) : align === TextAlign.Right ? width - line.width : 0);
|
|
110
|
+
for (const { glyph: g, x: gx0, color: c } of placeLine(fontOf, line, x)) {
|
|
111
|
+
const ink = c ?? color;
|
|
110
112
|
for (let gy = 0; gy < g.height; gy++) {
|
|
111
113
|
const y = y0 + gy;
|
|
112
114
|
if (y < rect.top || y >= rect.bottom || y >= bmp.height)
|
|
113
115
|
continue;
|
|
114
116
|
for (let gx = 0; gx < g.width; gx++) {
|
|
115
|
-
const px =
|
|
117
|
+
const px = gx0 + gx;
|
|
116
118
|
if (g.pixels[gy * g.width + gx] && px >= 0 && px < bmp.width)
|
|
117
|
-
bmp.pixels[y * bmp.width + px] =
|
|
119
|
+
bmp.pixels[y * bmp.width + px] = ink;
|
|
118
120
|
}
|
|
119
121
|
}
|
|
120
|
-
x += g.width;
|
|
121
122
|
}
|
|
122
|
-
|
|
123
|
+
y0 += line.height;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** The size text takes in a font, wrapped to maxWidth: the widest line's advance, and the lines' heights. */
|
|
127
|
+
function measure(vm, fontNo, str, maxWidth) {
|
|
128
|
+
const fontOf = (n) => text(vm).font(n);
|
|
129
|
+
const lines = wrapStyled(fontOf, parseStyled(str, fontNo), fontNo, maxWidth);
|
|
130
|
+
return { width: Math.max(0, ...lines.map((l) => l.width)), height: lines.reduce((h, l) => h + l.height, 0) };
|
|
123
131
|
}
|
|
124
132
|
function drawBorder(bmp, color) {
|
|
125
133
|
for (let x = 0; x < bmp.width; x++)
|
|
@@ -186,33 +194,36 @@ export const textKernels = {
|
|
|
186
194
|
},
|
|
187
195
|
// TextSize(rectArray, text, font, maxWidth): writes [left, top, right, bottom] (inclusive).
|
|
188
196
|
TextSize: (vm, [rect = 0, str = 0, fontNo = 0, maxWidth = 0]) => {
|
|
189
|
-
const font = text(vm).font(fontNo);
|
|
190
197
|
const s = stringHelpers.str(vm, str);
|
|
198
|
+
const fontOf = (n) => text(vm).font(n);
|
|
199
|
+
const oneLine = () => {
|
|
200
|
+
const chars = parseStyled(s, fontNo);
|
|
201
|
+
return { width: styledWidth(fontOf, chars), height: Math.max(fontOf(fontNo).height, ...chars.map((c) => fontOf(c.font).height)) };
|
|
202
|
+
};
|
|
191
203
|
let w, h;
|
|
192
204
|
const max = toSigned(maxWidth);
|
|
193
205
|
if (max >= 0) {
|
|
194
206
|
const limit = max || DEFAULT_MAX_WIDTH;
|
|
195
|
-
|
|
207
|
+
const one = oneLine();
|
|
208
|
+
if (!/[\r\n]/.test(s) && one.width <= limit) {
|
|
196
209
|
// Fits on one line: measured as-is, spaces included (a lone " " has a width).
|
|
197
|
-
w =
|
|
198
|
-
h = font.height;
|
|
210
|
+
({ width: w, height: h } = one);
|
|
199
211
|
}
|
|
200
212
|
else {
|
|
201
|
-
const
|
|
202
|
-
w = Math.min(limit,
|
|
203
|
-
h =
|
|
213
|
+
const m = measure(vm, fontNo, s, limit);
|
|
214
|
+
w = Math.min(limit, m.width);
|
|
215
|
+
h = m.height;
|
|
204
216
|
}
|
|
205
217
|
}
|
|
206
218
|
else {
|
|
207
|
-
w =
|
|
208
|
-
h = font.height;
|
|
219
|
+
({ width: w, height: h } = oneLine());
|
|
209
220
|
}
|
|
210
221
|
const a = stringHelpers.resolve(vm, rect);
|
|
211
222
|
if (a)
|
|
212
223
|
a.data.splice(0, 4, 0, 0, fromInt(w - 1), fromInt(h - 1));
|
|
213
224
|
return 0;
|
|
214
225
|
},
|
|
215
|
-
TextWidth: (vm, [str = 0, fontNo = 0]) =>
|
|
226
|
+
TextWidth: (vm, [str = 0, fontNo = 0]) => styledWidth((n) => text(vm).font(n), parseStyled(stringHelpers.str(vm, str), fontNo)),
|
|
216
227
|
PointSize: (vm, [fontNo = 0]) => text(vm).font(fontNo).height,
|
|
217
228
|
TextFonts: () => 0,
|
|
218
229
|
TextColors: () => 0,
|
|
@@ -225,7 +236,7 @@ export const textKernels = {
|
|
|
225
236
|
return NULL;
|
|
226
237
|
const w = Math.max(1, toSigned(width)), h = Math.max(1, toSigned(height));
|
|
227
238
|
const str = unescapeText(stringHelpers.str(vm, vm.getProp(obj, "text") ?? 0));
|
|
228
|
-
const
|
|
239
|
+
const fontNo = prop(vm, obj, "font");
|
|
229
240
|
const fore = prop(vm, obj, "fore") & 0xff;
|
|
230
241
|
const align = prop(vm, obj, "mode");
|
|
231
242
|
const rect = {
|
|
@@ -255,7 +266,7 @@ export const textKernels = {
|
|
|
255
266
|
if (found)
|
|
256
267
|
vm.memory.bitmap(ref).pixels.set(found.cel.pixels);
|
|
257
268
|
}
|
|
258
|
-
drawText(vm.memory.bitmap(ref),
|
|
269
|
+
drawText(vm, vm.memory.bitmap(ref), fontNo, str, rect, fore, align);
|
|
259
270
|
return ref;
|
|
260
271
|
},
|
|
261
272
|
/**
|
|
@@ -304,7 +315,7 @@ export const textKernels = {
|
|
|
304
315
|
if (border >= 0)
|
|
305
316
|
drawBorder(bmp, border & 0xff);
|
|
306
317
|
const cursor = (frame >> 4) & 1 ? "" : "_"; // blink
|
|
307
|
-
drawText(bmp, font, text + cursor, rect, fore, TextAlign.Left);
|
|
318
|
+
drawText(vm, bmp, prop(vm, obj, "font"), text + cursor, rect, fore, TextAlign.Left);
|
|
308
319
|
vm.setProp(obj, "bitmap", bitmap);
|
|
309
320
|
graphicsKernels.FrameOut(vm, []);
|
|
310
321
|
yield { frame: true };
|