sci2-ts 0.14.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.
@@ -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 out = new Uint8Array(6 + count * 2 + bodies.reduce((n, b) => n + b.length, 0));
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) => w + (font.glyphs[ch.charCodeAt(0)]?.width ?? 0), 0);
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
- /** Renders already-wrapped lines, each aligned within the bitmap width. */
106
- export function renderText(font: Font, lines: string[], align: "left" | "center" = "left"): TextBitmap {
107
- const width = Math.max(1, ...lines.map((l) => textWidth(font, l)));
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
- lines.forEach((line, row) => {
112
- let x = align === "center" ? Math.floor((width - textWidth(font, line)) / 2) : 0;
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[(row * font.height + gy) * width + x + gx] = 1;
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, textWidth, wrapText } from "../../text/font.js";
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
- /** Draws wrapped text into a bitmap, inside `rect`, with alignment. */
99
- function drawText(bmp, font, str, rect, color, align) {
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
- const lines = wrapText(font, str, width);
102
- lines.forEach((line, row) => {
103
- const w = textWidth(font, line);
104
- let x = rect.left + (align === TextAlign.Center ? Math.floor((width - w) / 2) : align === TextAlign.Right ? width - w : 0);
105
- const y0 = rect.top + row * font.height;
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 = x + gx;
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] = color;
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
- if (!/[\r\n]/.test(s) && textWidth(font, s) <= limit) {
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 = textWidth(font, s);
198
- h = font.height;
210
+ ({ width: w, height: h } = one);
199
211
  }
200
212
  else {
201
- const lines = wrapText(font, s, limit);
202
- w = Math.min(limit, Math.max(0, ...lines.map((l) => textWidth(font, l))));
203
- h = lines.length * font.height;
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 = textWidth(font, s);
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]) => textWidth(text(vm).font(fontNo), stringHelpers.str(vm, str)),
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 font = text(vm).font(prop(vm, obj, "font"));
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), font, str, rect, fore, align);
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 };
@@ -1,5 +1,6 @@
1
1
  import { ResourceType } from "../../resource/types.ts";
2
- import { parseFont, textWidth, wrapText, type Font } from "../../text/font.ts";
2
+ import { parseFont, type Font } from "../../text/font.ts";
3
+ import { parseStyled, placeLine, styledWidth, wrapStyled } from "../../text/styled.ts";
3
4
  import { hasRef, parseMessages, type MessageFile, type MessageTuple } from "../../text/message.ts";
4
5
  import type { SciBitmap } from "../memory.ts";
5
6
  import type { FrameRequest, KernelFn, SendRequest, Vm } from "../vm.ts";
@@ -106,28 +107,37 @@ const DEFAULT_MAX_WIDTH = (320 * 3) / 5;
106
107
  */
107
108
  export const unescapeText = (s: string) => s.replace(/\\([0-9a-fA-F]{2})/g, (_m, hex: string) => String.fromCharCode(parseInt(hex, 16)));
108
109
 
109
- /** Draws wrapped text into a bitmap, inside `rect`, with alignment. */
110
- function drawText(bmp: SciBitmap, font: Font, str: string, rect: { left: number; top: number; right: number; bottom: number }, color: number, align: number) {
110
+ /**
111
+ * Draws wrapped text into a bitmap, inside `rect`, with alignment. The text can change font
112
+ * and colour (`|f3|`, `|c5|`). Ink can start left of the pen or run past it (an italic's
113
+ * tail) into the box's margin; only the bitmap's edges stop it.
114
+ */
115
+ function drawText(vm: Vm, bmp: SciBitmap, fontNo: number, str: string, rect: { left: number; top: number; right: number; bottom: number }, color: number, align: number) {
116
+ const fontOf = (n: number) => text(vm).font(n);
111
117
  const width = rect.right - rect.left;
112
- const lines = wrapText(font, str, width);
113
- lines.forEach((line, row) => {
114
- const w = textWidth(font, line);
115
- let x = rect.left + (align === TextAlign.Center ? Math.floor((width - w) / 2) : align === TextAlign.Right ? width - w : 0);
116
- const y0 = rect.top + row * font.height;
117
- for (const ch of line) {
118
- const g = font.glyphs[ch.charCodeAt(0)];
119
- if (!g) continue;
118
+ let y0 = rect.top;
119
+ for (const line of wrapStyled(fontOf, parseStyled(str, fontNo), fontNo, width)) {
120
+ const x = rect.left + (align === TextAlign.Center ? Math.floor((width - line.width) / 2) : align === TextAlign.Right ? width - line.width : 0);
121
+ for (const { glyph: g, x: gx0, color: c } of placeLine(fontOf, line, x)) {
122
+ const ink = c ?? color;
120
123
  for (let gy = 0; gy < g.height; gy++) {
121
124
  const y = y0 + gy;
122
125
  if (y < rect.top || y >= rect.bottom || y >= bmp.height) continue;
123
126
  for (let gx = 0; gx < g.width; gx++) {
124
- const px = x + gx;
125
- if (g.pixels[gy * g.width + gx] && px >= 0 && px < bmp.width) bmp.pixels[y * bmp.width + px] = color;
127
+ const px = gx0 + gx;
128
+ if (g.pixels[gy * g.width + gx] && px >= 0 && px < bmp.width) bmp.pixels[y * bmp.width + px] = ink;
126
129
  }
127
130
  }
128
- x += g.width;
129
131
  }
130
- });
132
+ y0 += line.height;
133
+ }
134
+ }
135
+
136
+ /** The size text takes in a font, wrapped to maxWidth: the widest line's advance, and the lines' heights. */
137
+ function measure(vm: Vm, fontNo: number, str: string, maxWidth: number): { width: number; height: number } {
138
+ const fontOf = (n: number) => text(vm).font(n);
139
+ const lines = wrapStyled(fontOf, parseStyled(str, fontNo), fontNo, maxWidth);
140
+ return { width: Math.max(0, ...lines.map((l) => l.width)), height: lines.reduce((h, l) => h + l.height, 0) };
131
141
  }
132
142
 
133
143
  function drawBorder(bmp: SciBitmap, color: number) {
@@ -185,30 +195,33 @@ export const textKernels: Record<string, KernelFn> = {
185
195
 
186
196
  // TextSize(rectArray, text, font, maxWidth): writes [left, top, right, bottom] (inclusive).
187
197
  TextSize: (vm, [rect = 0, str = 0, fontNo = 0, maxWidth = 0]) => {
188
- const font = text(vm).font(fontNo);
189
198
  const s = stringHelpers.str(vm, str);
199
+ const fontOf = (n: number) => text(vm).font(n);
200
+ const oneLine = () => {
201
+ const chars = parseStyled(s, fontNo);
202
+ return { width: styledWidth(fontOf, chars), height: Math.max(fontOf(fontNo).height, ...chars.map((c) => fontOf(c.font).height)) };
203
+ };
190
204
  let w: number, h: number;
191
205
  const max = toSigned(maxWidth);
192
206
  if (max >= 0) {
193
207
  const limit = max || DEFAULT_MAX_WIDTH;
194
- if (!/[\r\n]/.test(s) && textWidth(font, s) <= limit) {
208
+ const one = oneLine();
209
+ if (!/[\r\n]/.test(s) && one.width <= limit) {
195
210
  // Fits on one line: measured as-is, spaces included (a lone " " has a width).
196
- w = textWidth(font, s);
197
- h = font.height;
211
+ ({ width: w, height: h } = one);
198
212
  } else {
199
- const lines = wrapText(font, s, limit);
200
- w = Math.min(limit, Math.max(0, ...lines.map((l) => textWidth(font, l))));
201
- h = lines.length * font.height;
213
+ const m = measure(vm, fontNo, s, limit);
214
+ w = Math.min(limit, m.width);
215
+ h = m.height;
202
216
  }
203
217
  } else {
204
- w = textWidth(font, s);
205
- h = font.height;
218
+ ({ width: w, height: h } = oneLine());
206
219
  }
207
220
  const a = stringHelpers.resolve(vm, rect);
208
221
  if (a) a.data.splice(0, 4, 0, 0, fromInt(w - 1), fromInt(h - 1));
209
222
  return 0;
210
223
  },
211
- TextWidth: (vm, [str = 0, fontNo = 0]) => textWidth(text(vm).font(fontNo), stringHelpers.str(vm, str)),
224
+ TextWidth: (vm, [str = 0, fontNo = 0]) => styledWidth((n) => text(vm).font(n), parseStyled(stringHelpers.str(vm, str), fontNo)),
212
225
  PointSize: (vm, [fontNo = 0]) => text(vm).font(fontNo).height,
213
226
  TextFonts: () => 0,
214
227
  TextColors: () => 0,
@@ -221,7 +234,7 @@ export const textKernels: Record<string, KernelFn> = {
221
234
  if (!vm.memory.object(obj)) return NULL;
222
235
  const w = Math.max(1, toSigned(width)), h = Math.max(1, toSigned(height));
223
236
  const str = unescapeText(stringHelpers.str(vm, vm.getProp(obj, "text") ?? 0));
224
- const font = text(vm).font(prop(vm, obj, "font"));
237
+ const fontNo = prop(vm, obj, "font");
225
238
  const fore = prop(vm, obj, "fore") & 0xff;
226
239
  const align = prop(vm, obj, "mode");
227
240
  const rect = {
@@ -247,7 +260,7 @@ export const textKernels: Record<string, KernelFn> = {
247
260
  ref = vm.memory.newBitmap(cw, ch, found?.cel.skipColor ?? 0, found?.cel.skipColor ?? 0);
248
261
  if (found) vm.memory.bitmap(ref)!.pixels.set(found.cel.pixels);
249
262
  }
250
- drawText(vm.memory.bitmap(ref)!, font, str, rect, fore, align);
263
+ drawText(vm, vm.memory.bitmap(ref)!, fontNo, str, rect, fore, align);
251
264
  return ref;
252
265
  },
253
266
 
@@ -295,7 +308,7 @@ export const textKernels: Record<string, KernelFn> = {
295
308
  const bmp = vm.memory.bitmap(bitmap)!;
296
309
  if (border >= 0) drawBorder(bmp, border & 0xff);
297
310
  const cursor = (frame >> 4) & 1 ? "" : "_"; // blink
298
- drawText(bmp, font, text + cursor, rect, fore, TextAlign.Left);
311
+ drawText(vm, bmp, prop(vm, obj, "font"), text + cursor, rect, fore, TextAlign.Left);
299
312
  vm.setProp(obj, "bitmap", bitmap);
300
313
  graphicsKernels.FrameOut!(vm, []);
301
314
  yield { frame: true };
@@ -24,8 +24,11 @@ export interface ArtManifest {
24
24
  mirror: boolean;
25
25
  })[];
26
26
  }[];
27
- /** Fonts drawn as sheets of glyphs (tools/art/font.ts). */
28
- fonts?: {
27
+ /**
28
+ * Fonts (tools/art/font.ts): drawn as sheets of glyphs, or a bitmap font with its own
29
+ * metrics, a JSON file (bearings, advances, its atlas) exported from the font's source.
30
+ */
31
+ fonts?: ({
29
32
  number: number;
30
33
  png: string;
31
34
  cell: [number, number];
@@ -33,7 +36,10 @@ export interface ArtManifest {
33
36
  spacing?: number;
34
37
  space?: number;
35
38
  lineHeight?: number;
36
- }[];
39
+ } | {
40
+ number: number;
41
+ metrics: string;
42
+ })[];
37
43
  }
38
44
  export declare class ArtError extends Error {
39
45
  }