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.
@@ -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 out = new Uint8Array(6 + count * 2 + bodies.reduce((n, b) => n + b.length, 0));
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) => w + (font.glyphs[ch.charCodeAt(0)]?.width ?? 0), 0);
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
- /** Renders already-wrapped lines, each aligned within the bitmap width. */
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 width = Math.max(1, ...lines.map((l) => textWidth(font, l)));
85
- const height = Math.max(1, lines.length * font.height);
86
- const pixels = new Uint8Array(width * height);
87
- lines.forEach((line, row) => {
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
- continue;
93
- for (let gy = 0; gy < g.height; gy++) {
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[(row * font.height + gy) * width + x + gx] = 1;
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 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 };