capture-flow 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +238 -2
- package/bin/capture-flow.mjs +12 -0
- package/package.json +55 -4
- package/src/build.ts +186 -0
- package/src/capture.ts +77 -0
- package/src/cli.ts +139 -0
- package/src/index.ts +11 -0
- package/src/manifest.ts +98 -0
- package/src/render.ts +75 -0
- package/src/verify.ts +90 -0
- package/vendor/wajha/LICENSE +21 -0
- package/vendor/wajha/NOTICE +21 -0
- package/vendor/wajha/engine/background.ts +55 -0
- package/vendor/wajha/engine/bidi.ts +589 -0
- package/vendor/wajha/engine/cache.ts +96 -0
- package/vendor/wajha/engine/color.ts +125 -0
- package/vendor/wajha/engine/devices/android.ts +53 -0
- package/vendor/wajha/engine/devices/browser.ts +170 -0
- package/vendor/wajha/engine/devices/finish.ts +154 -0
- package/vendor/wajha/engine/devices/geometry.ts +326 -0
- package/vendor/wajha/engine/devices/index.ts +143 -0
- package/vendor/wajha/engine/devices/ipad.ts +21 -0
- package/vendor/wajha/engine/devices/iphone.ts +55 -0
- package/vendor/wajha/engine/devices/macbook.ts +187 -0
- package/vendor/wajha/engine/devices/mock/apps/calendar.ts +145 -0
- package/vendor/wajha/engine/devices/mock/apps/chat.ts +213 -0
- package/vendor/wajha/engine/devices/mock/apps/finance.ts +164 -0
- package/vendor/wajha/engine/devices/mock/apps/fitness.ts +145 -0
- package/vendor/wajha/engine/devices/mock/apps/index.ts +14 -0
- package/vendor/wajha/engine/devices/mock/apps/meditation.ts +167 -0
- package/vendor/wajha/engine/devices/mock/apps/music.ts +152 -0
- package/vendor/wajha/engine/devices/mock/apps/shop.ts +116 -0
- package/vendor/wajha/engine/devices/mock/apps/social.ts +152 -0
- package/vendor/wajha/engine/devices/mock/apps/tasks.ts +131 -0
- package/vendor/wajha/engine/devices/mock/apps/travel.ts +121 -0
- package/vendor/wajha/engine/devices/mock/art.ts +557 -0
- package/vendor/wajha/engine/devices/mock/chrome.ts +337 -0
- package/vendor/wajha/engine/devices/mock/icons.ts +614 -0
- package/vendor/wajha/engine/devices/mock/kit.ts +213 -0
- package/vendor/wajha/engine/devices/mock/rng.ts +40 -0
- package/vendor/wajha/engine/devices/mock/theme.ts +133 -0
- package/vendor/wajha/engine/devices/mock/widgets.ts +231 -0
- package/vendor/wajha/engine/devices/paths.ts +113 -0
- package/vendor/wajha/engine/devices/screen.ts +75 -0
- package/vendor/wajha/engine/devices/shadow.ts +124 -0
- package/vendor/wajha/engine/devices/slab.ts +200 -0
- package/vendor/wajha/engine/devices/vision.ts +43 -0
- package/vendor/wajha/engine/devices/watch.ts +14 -0
- package/vendor/wajha/engine/geometry.ts +152 -0
- package/vendor/wajha/engine/images.ts +96 -0
- package/vendor/wajha/engine/index.ts +20 -0
- package/vendor/wajha/engine/linebreak.ts +226 -0
- package/vendor/wajha/engine/markup.ts +84 -0
- package/vendor/wajha/engine/mock.ts +158 -0
- package/vendor/wajha/engine/paint.ts +474 -0
- package/vendor/wajha/engine/patterns.ts +263 -0
- package/vendor/wajha/engine/render.ts +364 -0
- package/vendor/wajha/engine/shapes.ts +260 -0
- package/vendor/wajha/engine/testing/canvasNode.ts +121 -0
- package/vendor/wajha/engine/testing/fakeDevices.ts +85 -0
- package/vendor/wajha/engine/text.ts +694 -0
- package/vendor/wajha/engine/types.ts +190 -0
- package/vendor/wajha/export/paths.ts +197 -0
- package/vendor/wajha/export/png.ts +223 -0
- package/vendor/wajha/export/types.ts +60 -0
- package/vendor/wajha/model/adapt.ts +889 -0
- package/vendor/wajha/model/catalog.ts +369 -0
- package/vendor/wajha/model/clone.ts +58 -0
- package/vendor/wajha/model/colorMath.ts +128 -0
- package/vendor/wajha/model/defaults.ts +386 -0
- package/vendor/wajha/model/geometry.ts +95 -0
- package/vendor/wajha/model/ids.ts +13 -0
- package/vendor/wajha/model/instantiate.ts +280 -0
- package/vendor/wajha/model/mirror.ts +43 -0
- package/vendor/wajha/model/resolve.ts +87 -0
- package/vendor/wajha/model/rules.ts +249 -0
- package/vendor/wajha/model/stub.ts +4 -0
- package/vendor/wajha/model/types.ts +456 -0
- package/vendor/wajha/model/typography.ts +28 -0
- package/vendor/wajha/model/validate.ts +917 -0
- package/vendor/wajha/templates/builtin/aurora.ts +163 -0
- package/vendor/wajha/templates/builtin/bubblegum.ts +132 -0
- package/vendor/wajha/templates/builtin/citrusPop.ts +124 -0
- package/vendor/wajha/templates/builtin/clayGarden.ts +114 -0
- package/vendor/wajha/templates/builtin/desertDune.ts +97 -0
- package/vendor/wajha/templates/builtin/glassPrism.ts +139 -0
- package/vendor/wajha/templates/builtin/launchpad.ts +140 -0
- package/vendor/wajha/templates/builtin/loudAndClear.ts +109 -0
- package/vendor/wajha/templates/builtin/midnightNeon.ts +108 -0
- package/vendor/wajha/templates/builtin/monoSwiss.ts +149 -0
- package/vendor/wajha/templates/builtin/noirGold.ts +149 -0
- package/vendor/wajha/templates/builtin/noor.ts +107 -0
- package/vendor/wajha/templates/builtin/obsidian.ts +137 -0
- package/vendor/wajha/templates/builtin/panoramaPeaks.ts +145 -0
- package/vendor/wajha/templates/builtin/panoramaRibbon.ts +138 -0
- package/vendor/wajha/templates/builtin/panoramaWaves.ts +138 -0
- package/vendor/wajha/templates/builtin/paperAndInk.ts +98 -0
- package/vendor/wajha/templates/builtin/sage.ts +94 -0
- package/vendor/wajha/templates/builtin/splitTone.ts +129 -0
- package/vendor/wajha/templates/builtin/sunsetBoulevard.ts +127 -0
- package/vendor/wajha/templates/index.ts +55 -0
- package/vendor/wajha/templates/kit.ts +761 -0
- package/vendor/wajha/ui/sortShots.ts +239 -0
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text elements: markup runs, wrapping, auto-fit, alignment, right-to-left
|
|
3
|
+
* paragraphs, highlight colours and marker pills.
|
|
4
|
+
*
|
|
5
|
+
* Layout and drawing are split so callers can measure without painting
|
|
6
|
+
* (`layoutText`, the public contract) and the renderer can draw a layout it
|
|
7
|
+
* already measured (`drawTextBlock`). All measuring goes through the same
|
|
8
|
+
* context state (font, direction, letter spacing) that drawing uses, so the
|
|
9
|
+
* wrapped lines always match what ends up on the canvas.
|
|
10
|
+
*/
|
|
11
|
+
import type { TextStyle } from '../model/types';
|
|
12
|
+
import { FONT_FALLBACK_STACK, languageInfo } from '../model/catalog';
|
|
13
|
+
import type { Ctx2D, LayoutTextFn, LayoutTextInput, TextLayout, TextLine, TextRun } from './types';
|
|
14
|
+
import { contrast, rgba } from './color';
|
|
15
|
+
import { clamp, num, roundedRect } from './geometry';
|
|
16
|
+
import { engineCache } from './cache';
|
|
17
|
+
import { parseMarkup } from './markup';
|
|
18
|
+
import { isUnidirectional, resolveLevels, strongDirections, visualOrder } from './bidi';
|
|
19
|
+
import { graphemes, segmentText, type Segmentation } from './linebreak';
|
|
20
|
+
|
|
21
|
+
const MIN_FONT_PX = 1;
|
|
22
|
+
const MAX_FONT_PX = 4000;
|
|
23
|
+
/** Auto-fit shrinks in 4 % steps down to 60 % of the style size. */
|
|
24
|
+
const FIT_STEPS = 10;
|
|
25
|
+
const FIT_STEP = 0.04;
|
|
26
|
+
/** Texts are length-limited by the validators; this only protects against runaway input. */
|
|
27
|
+
const MAX_TEXT_LENGTH = 10000;
|
|
28
|
+
const EPS = 0.01;
|
|
29
|
+
/** Marker pill padding around the highlighted words, in em. */
|
|
30
|
+
const PILL_PAD_X = 0.14;
|
|
31
|
+
const PILL_PAD_Y = 0.04;
|
|
32
|
+
|
|
33
|
+
/** A TextStyle with every field present, finite and in range. */
|
|
34
|
+
export interface SafeTextStyle {
|
|
35
|
+
font: string;
|
|
36
|
+
weight: number;
|
|
37
|
+
size: number;
|
|
38
|
+
lineHeight: number;
|
|
39
|
+
letterSpacing: number;
|
|
40
|
+
color: string;
|
|
41
|
+
highlight: string;
|
|
42
|
+
highlightMode: 'color' | 'marker';
|
|
43
|
+
align: 'start' | 'center' | 'end';
|
|
44
|
+
transform: 'none' | 'uppercase';
|
|
45
|
+
maxLines: number;
|
|
46
|
+
shadow: { color: string; blur: number; x: number; y: number } | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const str = (v: unknown, fallback: string): string => (typeof v === 'string' ? v : fallback);
|
|
50
|
+
|
|
51
|
+
/** Normalises a (possibly malformed) TextStyle. */
|
|
52
|
+
export function safeTextStyle(style: Partial<TextStyle> | null | undefined): SafeTextStyle {
|
|
53
|
+
const s: Partial<TextStyle> = style && typeof style === 'object' ? style : {};
|
|
54
|
+
const color = str(s.color, '#111111');
|
|
55
|
+
const sh: unknown = s.shadow;
|
|
56
|
+
let shadow: SafeTextStyle['shadow'] = null;
|
|
57
|
+
if (sh && typeof sh === 'object') {
|
|
58
|
+
const o = sh as Record<string, unknown>;
|
|
59
|
+
shadow = { color: str(o.color, '#00000040'), blur: Math.max(0, num(o.blur, 0)), x: num(o.x, 0), y: num(o.y, 0) };
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
font: str(s.font, ''),
|
|
63
|
+
weight: Math.round(clamp(num(s.weight, 400), 1, 1000)),
|
|
64
|
+
size: clamp(num(s.size, 0.05), 0, 20),
|
|
65
|
+
lineHeight: clamp(num(s.lineHeight, 1.2), 0.3, 5),
|
|
66
|
+
letterSpacing: clamp(num(s.letterSpacing, 0), -0.5, 2),
|
|
67
|
+
color,
|
|
68
|
+
highlight: str(s.highlight, color),
|
|
69
|
+
highlightMode: s.highlightMode === 'marker' ? 'marker' : 'color',
|
|
70
|
+
align: s.align === 'center' || s.align === 'end' ? s.align : 'start',
|
|
71
|
+
transform: s.transform === 'uppercase' ? 'uppercase' : 'none',
|
|
72
|
+
maxLines: Math.floor(clamp(num(s.maxLines, 0), 0, 1000)),
|
|
73
|
+
shadow,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Canvas font shorthand: `WEIGHT PXpx "FONT", <fallback stack>`. */
|
|
78
|
+
export function fontString(family: string, weight: number, px: number): string {
|
|
79
|
+
// Quotes or semicolons in a family name would make the whole shorthand invalid and the
|
|
80
|
+
// canvas would silently keep the previous font.
|
|
81
|
+
const name = family.replace(/["\\\r\n;{}]/g, '').trim();
|
|
82
|
+
const size = Math.max(0.01, Math.round(px * 100) / 100);
|
|
83
|
+
const w = Math.round(clamp(num(weight, 400), 1, 1000));
|
|
84
|
+
return `${w} ${size}px ${name ? `"${name}", ` : ''}${FONT_FALLBACK_STACK}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* ------------------------------------------------------------------ */
|
|
88
|
+
/* Paragraph preparation (cached: independent of font size) */
|
|
89
|
+
/* ------------------------------------------------------------------ */
|
|
90
|
+
|
|
91
|
+
interface ParaRun {
|
|
92
|
+
start: number;
|
|
93
|
+
end: number;
|
|
94
|
+
highlighted: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface Para {
|
|
98
|
+
text: string;
|
|
99
|
+
runs: ParaRun[];
|
|
100
|
+
/** Base direction used for bidi and letter-spacing decisions. */
|
|
101
|
+
rtl: boolean;
|
|
102
|
+
/** Letter spacing may be applied (not RTL, no cursive-joining script). */
|
|
103
|
+
spaced: boolean;
|
|
104
|
+
seg: Segmentation;
|
|
105
|
+
/** Start and end offsets of the highlighted runs a marker pill is drawn around (sorted). */
|
|
106
|
+
pills: number[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Scripts whose letters join; per-letter spacing would tear them apart. */
|
|
110
|
+
const JOINING = /[\u0600-\u08ff\u1800-\u18af\ua840-\ua87f\ufb50-\ufdff\ufe70-\ufeff]|[\u{10ac0}-\u{10aff}\u{10d00}-\u{10d3f}\u{10f30}-\u{10f6f}]/u;
|
|
111
|
+
/** Something visible for a marker pill to sit behind (not only spaces or invisible format characters). */
|
|
112
|
+
const INK = /[^\s\p{Cf}]/u;
|
|
113
|
+
|
|
114
|
+
const prepared = engineCache<string, Para[]>(160);
|
|
115
|
+
|
|
116
|
+
function upper(s: string, lang: string): string {
|
|
117
|
+
try {
|
|
118
|
+
return s.toLocaleUpperCase(lang);
|
|
119
|
+
} catch {
|
|
120
|
+
// Invalid language tags throw RangeError.
|
|
121
|
+
return s.toUpperCase();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function prepare(text: string, style: SafeTextStyle, lang: string, baseRtl: boolean, wrap: 'space' | 'char'): Para[] {
|
|
126
|
+
const key = `${lang}\u0000${wrap}\u0000${style.transform}\u0000${baseRtl ? 1 : 0}\u0000${style.letterSpacing !== 0 ? 1 : 0}\u0000${text}`;
|
|
127
|
+
const hit = prepared.get(key);
|
|
128
|
+
if (hit) return hit;
|
|
129
|
+
const paras = parseMarkup(text).map((runs): Para => {
|
|
130
|
+
let t = '';
|
|
131
|
+
const out: ParaRun[] = [];
|
|
132
|
+
for (const r of runs) {
|
|
133
|
+
const s = style.transform === 'uppercase' ? upper(r.text, lang) : r.text;
|
|
134
|
+
if (!s) continue;
|
|
135
|
+
out.push({ start: t.length, end: t.length + s.length, highlighted: r.highlighted });
|
|
136
|
+
t += s;
|
|
137
|
+
}
|
|
138
|
+
// A paragraph written only in the opposite script (an untranslated fallback, a Latin
|
|
139
|
+
// tagline in an Arabic project) reads in its own direction; mixed text keeps the
|
|
140
|
+
// language direction so "Wajha يساعدك" still starts on the right.
|
|
141
|
+
const dirs = strongDirections(t);
|
|
142
|
+
const rtl = baseRtl ? !(dirs.ltr && !dirs.rtl) : dirs.rtl && !dirs.ltr;
|
|
143
|
+
const pills: number[] = [];
|
|
144
|
+
for (const r of out) if (r.highlighted && INK.test(t.slice(r.start, r.end))) pills.push(r.start, r.end);
|
|
145
|
+
return {
|
|
146
|
+
text: t,
|
|
147
|
+
runs: out,
|
|
148
|
+
rtl,
|
|
149
|
+
spaced: style.letterSpacing !== 0 && !rtl && !JOINING.test(t),
|
|
150
|
+
seg: segmentText(t, lang, wrap),
|
|
151
|
+
pills,
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
prepared.set(key, paras);
|
|
155
|
+
return paras;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* ------------------------------------------------------------------ */
|
|
159
|
+
/* Measuring */
|
|
160
|
+
/* ------------------------------------------------------------------ */
|
|
161
|
+
|
|
162
|
+
const finiteWidth = (w: number) => (Number.isFinite(w) && w > 0 ? w : 0);
|
|
163
|
+
|
|
164
|
+
function setSpacing(ctx: Ctx2D, px: number): void {
|
|
165
|
+
ctx.letterSpacing = `${Math.round(px * 100) / 100}px`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Width of `text` with letter spacing. Native `ctx.letterSpacing` must
|
|
170
|
+
* already be set by the caller; without native support the spacing is
|
|
171
|
+
* emulated per grapheme (and drawn the same way, see drawSpaced).
|
|
172
|
+
*/
|
|
173
|
+
function measureWith(ctx: Ctx2D, text: string, spacing: number, native: boolean, lang: string): number {
|
|
174
|
+
if (!text) return 0;
|
|
175
|
+
if (spacing === 0 || native) return finiteWidth(ctx.measureText(text).width);
|
|
176
|
+
let w = 0;
|
|
177
|
+
let n = 0;
|
|
178
|
+
for (const g of graphemes(text, lang)) {
|
|
179
|
+
w += finiteWidth(ctx.measureText(g).width);
|
|
180
|
+
n++;
|
|
181
|
+
}
|
|
182
|
+
return Math.max(0, w + n * spacing);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* ------------------------------------------------------------------ */
|
|
186
|
+
/* Layout */
|
|
187
|
+
/* ------------------------------------------------------------------ */
|
|
188
|
+
|
|
189
|
+
/** A laid-out line plus what drawing needs to reproduce its measurement. */
|
|
190
|
+
export interface LaidLine extends TextLine {
|
|
191
|
+
/** Base direction of the paragraph the line belongs to. */
|
|
192
|
+
rtl: boolean;
|
|
193
|
+
/** Letter spacing in pixels applied to this line (0 when disabled). */
|
|
194
|
+
spacing: number;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The public TextLayout plus the state `drawTextBlock` needs. */
|
|
198
|
+
export interface LaidText extends TextLayout {
|
|
199
|
+
lines: LaidLine[];
|
|
200
|
+
font: string;
|
|
201
|
+
style: SafeTextStyle;
|
|
202
|
+
/** Alignment direction: the slide language, so start/end mirror for RTL. */
|
|
203
|
+
rtl: boolean;
|
|
204
|
+
lang: string;
|
|
205
|
+
/** Letter spacing uses ctx.letterSpacing (else emulated per grapheme). */
|
|
206
|
+
nativeSpacing: boolean;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface LayoutOptions {
|
|
210
|
+
/** Overrides feature detection of ctx.letterSpacing (tests). */
|
|
211
|
+
nativeSpacing?: boolean;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
interface Attempt {
|
|
215
|
+
lines: LaidLine[];
|
|
216
|
+
/** A word had to be split at grapheme boundaries. */
|
|
217
|
+
broke: boolean;
|
|
218
|
+
/** Per paragraph: total content width on one line (for cheap auto-fit estimates). */
|
|
219
|
+
widths: number[];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** How many of the sorted offsets lie in [from, to). */
|
|
223
|
+
function countIn(offsets: number[], from: number, to: number): number {
|
|
224
|
+
return to > from && offsets.length ? lowerBound(offsets, to) - lowerBound(offsets, from) : 0;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Pill padding inside the line [start, end): the pills' outer sides at the line ends hang free. */
|
|
228
|
+
const pillPadding = (p: Para, pad: number, start: number, end: number): number => (pad > 0 ? pad * countIn(p.pills, start + 1, end) : 0);
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Greedy line breaking. `pad` is the room a marker pill keeps on each side
|
|
232
|
+
* of its words (0 without pills), counted wherever the pill has a
|
|
233
|
+
* neighbour on the same line, like a space.
|
|
234
|
+
*/
|
|
235
|
+
function breakParagraph(
|
|
236
|
+
p: Para,
|
|
237
|
+
measure: (s: string) => number,
|
|
238
|
+
boxWidth: number,
|
|
239
|
+
pad: number,
|
|
240
|
+
): { ranges: [number, number][]; broke: boolean; total: number } {
|
|
241
|
+
const ranges: [number, number][] = [];
|
|
242
|
+
const { segments, graphemes: bounds } = p.seg;
|
|
243
|
+
const pills = pad > 0 ? p.pills : [];
|
|
244
|
+
let broke = false;
|
|
245
|
+
let total = 0;
|
|
246
|
+
let lineStart = -1;
|
|
247
|
+
let lineEnd = -1;
|
|
248
|
+
let lineWidth = 0; // includes the trailing spaces (and pill padding) of the last segment
|
|
249
|
+
for (const seg of segments) {
|
|
250
|
+
// Padding at a pill edge before the word counts once another word precedes it on the
|
|
251
|
+
// line, padding after it only if the line goes on, padding inside the word always.
|
|
252
|
+
const lead = pad * countIn(pills, seg.start, seg.start + 1);
|
|
253
|
+
const trail = pad * countIn(pills, seg.end, seg.wsEnd);
|
|
254
|
+
const inner = pad * countIn(pills, seg.start + 1, seg.end);
|
|
255
|
+
const cw = measure(p.text.slice(seg.start, seg.end)) + inner;
|
|
256
|
+
const fw = (seg.wsEnd > seg.end ? measure(p.text.slice(seg.start, seg.wsEnd)) + inner : cw) + trail;
|
|
257
|
+
// Content only: a strict lower bound of the wrapped width (spaces at breaks are dropped).
|
|
258
|
+
total += cw;
|
|
259
|
+
if (lineStart >= 0 && lineWidth + lead + cw <= boxWidth + EPS) {
|
|
260
|
+
lineEnd = seg.end;
|
|
261
|
+
lineWidth += lead + fw;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (lineStart >= 0) ranges.push([lineStart, lineEnd]);
|
|
265
|
+
if (cw <= boxWidth + EPS) {
|
|
266
|
+
lineStart = seg.start;
|
|
267
|
+
lineEnd = seg.end;
|
|
268
|
+
lineWidth = fw;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
// A single word wider than the box: break it between graphemes.
|
|
272
|
+
broke = true;
|
|
273
|
+
let gi = lowerBound(bounds, seg.start);
|
|
274
|
+
let chunkStart = seg.start;
|
|
275
|
+
let chunkWidth = 0;
|
|
276
|
+
for (; gi + 1 < bounds.length && bounds[gi] < seg.end; gi++) {
|
|
277
|
+
const gw = measure(p.text.slice(bounds[gi], bounds[gi + 1]));
|
|
278
|
+
const gp = chunkWidth > 0 ? pad * countIn(pills, bounds[gi], bounds[gi] + 1) : 0;
|
|
279
|
+
if (chunkWidth > 0 && chunkWidth + gp + gw > boxWidth + EPS) {
|
|
280
|
+
ranges.push([chunkStart, bounds[gi]]);
|
|
281
|
+
chunkStart = bounds[gi];
|
|
282
|
+
chunkWidth = 0;
|
|
283
|
+
} else chunkWidth += gp;
|
|
284
|
+
chunkWidth += gw;
|
|
285
|
+
}
|
|
286
|
+
lineStart = chunkStart;
|
|
287
|
+
lineEnd = seg.end;
|
|
288
|
+
lineWidth = chunkWidth + Math.max(0, fw - cw);
|
|
289
|
+
}
|
|
290
|
+
if (lineStart >= 0) ranges.push([lineStart, lineEnd]);
|
|
291
|
+
if (!ranges.length) ranges.push([0, 0]);
|
|
292
|
+
return { ranges, broke, total };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function lowerBound(arr: number[], v: number): number {
|
|
296
|
+
let lo = 0;
|
|
297
|
+
let hi = arr.length;
|
|
298
|
+
while (lo < hi) {
|
|
299
|
+
const mid = (lo + hi) >> 1;
|
|
300
|
+
if (arr[mid] < v) lo = mid + 1;
|
|
301
|
+
else hi = mid;
|
|
302
|
+
}
|
|
303
|
+
return lo;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Paragraphs with this many lines get balanced line lengths (like CSS `text-wrap: balance`). */
|
|
307
|
+
const BALANCE_MIN_LINES = 2;
|
|
308
|
+
const BALANCE_MAX_LINES = 6;
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Narrowest wrap width that keeps the greedy line count, found by binary
|
|
312
|
+
* search. Greedy wrapping leaves orphans ("…one day at a / time."); equal
|
|
313
|
+
* lines read as designed, which is what screenshot headlines want. The box
|
|
314
|
+
* itself does not change, only where the lines break.
|
|
315
|
+
*/
|
|
316
|
+
function balanceRanges(p: Para, measure: (s: string) => number, boxWidth: number, pad: number, greedy: [number, number][]): [number, number][] {
|
|
317
|
+
const n = greedy.length;
|
|
318
|
+
let lo = 0;
|
|
319
|
+
let total = 0;
|
|
320
|
+
for (const seg of p.seg.segments) {
|
|
321
|
+
const w = measure(p.text.slice(seg.start, seg.end));
|
|
322
|
+
lo = Math.max(lo, w);
|
|
323
|
+
total += w;
|
|
324
|
+
}
|
|
325
|
+
lo = Math.max(lo, total / n);
|
|
326
|
+
let hi = boxWidth;
|
|
327
|
+
let best = greedy;
|
|
328
|
+
for (let i = 0; i < 16 && hi - lo > 0.5; i++) {
|
|
329
|
+
const mid = (lo + hi) / 2;
|
|
330
|
+
const r = breakParagraph(p, measure, mid, pad);
|
|
331
|
+
if (!r.broke && r.ranges.length <= n) {
|
|
332
|
+
hi = mid;
|
|
333
|
+
best = r.ranges;
|
|
334
|
+
} else lo = mid;
|
|
335
|
+
}
|
|
336
|
+
return best;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function attempt(ctx: Ctx2D, paras: Para[], px: number, style: SafeTextStyle, boxWidth: number, native: boolean, lang: string, balance = false): Attempt {
|
|
340
|
+
ctx.font = fontString(style.font, style.weight, px);
|
|
341
|
+
// Marker pills keep their padding clear of the neighbouring words (see pillGaps).
|
|
342
|
+
const pad = style.highlightMode === 'marker' ? PILL_PAD_X * px : 0;
|
|
343
|
+
const lines: LaidLine[] = [];
|
|
344
|
+
const widths: number[] = [];
|
|
345
|
+
let broke = false;
|
|
346
|
+
for (const p of paras) {
|
|
347
|
+
const spacing = p.spaced ? style.letterSpacing * px : 0;
|
|
348
|
+
ctx.direction = p.rtl ? 'rtl' : 'ltr';
|
|
349
|
+
if (native) setSpacing(ctx, spacing);
|
|
350
|
+
const memo = new Map<string, number>();
|
|
351
|
+
const measure = (s: string): number => {
|
|
352
|
+
let w = memo.get(s);
|
|
353
|
+
if (w === undefined) {
|
|
354
|
+
w = measureWith(ctx, s, spacing, native, lang);
|
|
355
|
+
memo.set(s, w);
|
|
356
|
+
}
|
|
357
|
+
return w;
|
|
358
|
+
};
|
|
359
|
+
const res = breakParagraph(p, measure, boxWidth, pad);
|
|
360
|
+
broke ||= res.broke;
|
|
361
|
+
widths.push(res.total);
|
|
362
|
+
const count = res.ranges.length;
|
|
363
|
+
const ranges = balance && !res.broke && count >= BALANCE_MIN_LINES && count <= BALANCE_MAX_LINES ? balanceRanges(p, measure, boxWidth, pad, res.ranges) : res.ranges;
|
|
364
|
+
for (const [start, end] of ranges) {
|
|
365
|
+
const runs: TextRun[] = [];
|
|
366
|
+
let width = pillPadding(p, pad, start, end);
|
|
367
|
+
for (const r of p.runs) {
|
|
368
|
+
const s = Math.max(start, r.start);
|
|
369
|
+
const e = Math.min(end, r.end);
|
|
370
|
+
if (s >= e) continue;
|
|
371
|
+
const text = p.text.slice(s, e);
|
|
372
|
+
const w = measure(text);
|
|
373
|
+
runs.push({ text, highlighted: r.highlighted, width: w });
|
|
374
|
+
width += w;
|
|
375
|
+
}
|
|
376
|
+
lines.push({ runs, width, rtl: p.rtl, spacing });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return { lines, broke, widths };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Lays out text with the style's font, wrapping, letter spacing and
|
|
384
|
+
* auto-fit. Returns the public TextLayout fields plus drawing state.
|
|
385
|
+
*/
|
|
386
|
+
export function layoutTextBlock(ctx: Ctx2D, input: LayoutTextInput, opts: LayoutOptions = {}): LaidText {
|
|
387
|
+
const style = safeTextStyle(input?.style);
|
|
388
|
+
const unit = num(input?.unit, 0) > 0 ? input.unit : 1;
|
|
389
|
+
const boxWidth = Math.max(0, num(input?.boxWidth, 0));
|
|
390
|
+
const lang = typeof input?.lang === 'string' && input.lang ? input.lang : 'en-US';
|
|
391
|
+
const rtl = input?.rtl === true;
|
|
392
|
+
const raw = typeof input?.text === 'string' ? input.text : '';
|
|
393
|
+
const text = raw.length > MAX_TEXT_LENGTH ? raw.slice(0, MAX_TEXT_LENGTH) : raw;
|
|
394
|
+
const basePx = clamp(style.size * unit, MIN_FONT_PX, MAX_FONT_PX);
|
|
395
|
+
const paras = prepare(text, style, lang, rtl, languageInfo(lang).wrap);
|
|
396
|
+
const native = opts.nativeSpacing ?? 'letterSpacing' in ctx;
|
|
397
|
+
|
|
398
|
+
const build = (chosen: Attempt, px: number, overflow: boolean): LaidText => {
|
|
399
|
+
// Auto-fit decides on greedy layouts; balancing keeps the line count, so it runs once at the end.
|
|
400
|
+
const a = chosen.lines.length >= BALANCE_MIN_LINES ? attempt(ctx, paras, px, style, boxWidth, native, lang, true) : chosen;
|
|
401
|
+
const lineHeightPx = px * style.lineHeight;
|
|
402
|
+
return {
|
|
403
|
+
lines: a.lines,
|
|
404
|
+
fontPx: px,
|
|
405
|
+
lineHeightPx,
|
|
406
|
+
boxWidth,
|
|
407
|
+
height: Math.max(1, a.lines.length) * lineHeightPx,
|
|
408
|
+
overflow,
|
|
409
|
+
font: fontString(style.font, style.weight, px),
|
|
410
|
+
style,
|
|
411
|
+
rtl,
|
|
412
|
+
lang,
|
|
413
|
+
nativeSpacing: native,
|
|
414
|
+
};
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
ctx.save();
|
|
418
|
+
try {
|
|
419
|
+
const first = attempt(ctx, paras, basePx, style, boxWidth, native, lang);
|
|
420
|
+
const max = style.maxLines;
|
|
421
|
+
if (max <= 0 || (first.lines.length <= max && !first.broke)) return build(first, basePx, false);
|
|
422
|
+
|
|
423
|
+
let fitted: { a: Attempt; px: number } | null = first.lines.length <= max ? { a: first, px: basePx } : null;
|
|
424
|
+
let last = { a: first, px: basePx };
|
|
425
|
+
for (let i = 1; i <= FIT_STEPS; i++) {
|
|
426
|
+
const scale = Math.round((1 - FIT_STEP * i) * 100) / 100;
|
|
427
|
+
const px = Math.max(MIN_FONT_PX, basePx * scale);
|
|
428
|
+
// Skip sizes whose single-line width already proves they need too many lines
|
|
429
|
+
// (widths scale linearly with the font size; 2 % margin for hinting).
|
|
430
|
+
if (i < FIT_STEPS && boxWidth > 0) {
|
|
431
|
+
let estimate = 0;
|
|
432
|
+
for (const w of first.widths) estimate += Math.max(1, Math.ceil((w * scale * 0.98) / boxWidth));
|
|
433
|
+
if (estimate > max) continue;
|
|
434
|
+
}
|
|
435
|
+
const a = attempt(ctx, paras, px, style, boxWidth, native, lang);
|
|
436
|
+
last = { a, px };
|
|
437
|
+
if (a.lines.length <= max) {
|
|
438
|
+
if (!a.broke) return build(a, px, false);
|
|
439
|
+
fitted ??= { a, px };
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// Prefer the largest size that fits the line count, even if a long word had to break.
|
|
443
|
+
if (fitted) return build(fitted.a, fitted.px, false);
|
|
444
|
+
return build(last.a, last.px, last.a.lines.length > max);
|
|
445
|
+
} finally {
|
|
446
|
+
if (native && 'letterSpacing' in ctx) setSpacing(ctx, 0);
|
|
447
|
+
ctx.restore();
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Public contract: lays out text without drawing (see LayoutTextFn). */
|
|
452
|
+
export const layoutText: LayoutTextFn = (ctx, input) => layoutTextBlock(ctx, input);
|
|
453
|
+
|
|
454
|
+
/* ------------------------------------------------------------------ */
|
|
455
|
+
/* Drawing */
|
|
456
|
+
/* ------------------------------------------------------------------ */
|
|
457
|
+
|
|
458
|
+
interface Piece {
|
|
459
|
+
text: string;
|
|
460
|
+
highlighted: boolean;
|
|
461
|
+
width: number;
|
|
462
|
+
/** Drawing direction of this piece. */
|
|
463
|
+
rtl: boolean;
|
|
464
|
+
spacing: number;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
interface Placed extends Piece {
|
|
468
|
+
x: number;
|
|
469
|
+
baseline: number;
|
|
470
|
+
line: number;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Runs of a line in visual (left-to-right) order, cut wherever the bidi
|
|
475
|
+
* level changes, even inside one run: every piece is drawn by its own
|
|
476
|
+
* fillText in a single direction (see bidi.ts for why WebKit needs that).
|
|
477
|
+
* Levels never change inside a word of one script, so Arabic letters stay
|
|
478
|
+
* joined.
|
|
479
|
+
*/
|
|
480
|
+
function linePieces(ctx: Ctx2D, line: LaidLine, native: boolean, lang: string): Piece[] {
|
|
481
|
+
const runs = line.runs;
|
|
482
|
+
const joined = runs.length === 1 ? runs[0].text : runs.map((r) => r.text).join('');
|
|
483
|
+
if (isUnidirectional(joined, line.rtl)) {
|
|
484
|
+
const pieces = runs.map((r): Piece => ({ text: r.text, highlighted: r.highlighted, width: r.width, rtl: line.rtl, spacing: line.spacing }));
|
|
485
|
+
return line.rtl ? pieces.reverse() : pieces;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const levels = resolveLevels(joined, line.rtl);
|
|
489
|
+
const items: { text: string; highlighted: boolean; level: number }[] = [];
|
|
490
|
+
let offset = 0;
|
|
491
|
+
for (const r of runs) {
|
|
492
|
+
let s = 0;
|
|
493
|
+
for (let k = 1; k <= r.text.length; k++) {
|
|
494
|
+
if (k === r.text.length || levels[offset + k] !== levels[offset + s]) {
|
|
495
|
+
items.push({ text: r.text.slice(s, k), highlighted: r.highlighted, level: levels[offset + s] });
|
|
496
|
+
s = k;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
offset += r.text.length;
|
|
500
|
+
}
|
|
501
|
+
if (native) setSpacing(ctx, line.spacing);
|
|
502
|
+
return visualOrder(items.map((it) => it.level)).map((idx) => {
|
|
503
|
+
const it = items[idx];
|
|
504
|
+
const rtl = it.level % 2 === 1;
|
|
505
|
+
ctx.direction = rtl ? 'rtl' : 'ltr';
|
|
506
|
+
return { text: it.text, highlighted: it.highlighted, width: measureWith(ctx, it.text, line.spacing, native, lang), rtl, spacing: line.spacing };
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Room to leave before each piece of a line for marker pills: a pill grows
|
|
512
|
+
* by `pad` on each side, and reserving that keeps a full word space between
|
|
513
|
+
* the pill and its neighbours. At the line ends the pill hangs free, so the
|
|
514
|
+
* text stays aligned. layoutTextBlock counts the same room when wrapping.
|
|
515
|
+
*/
|
|
516
|
+
function pillGaps(pieces: Piece[], pad: number): number[] {
|
|
517
|
+
const gaps = pieces.map(() => 0);
|
|
518
|
+
if (pad <= 0) return gaps;
|
|
519
|
+
for (let i = 0; i < pieces.length; i++) {
|
|
520
|
+
if (!pieces[i].highlighted) continue;
|
|
521
|
+
let j = i;
|
|
522
|
+
while (j + 1 < pieces.length && pieces[j + 1].highlighted) j++;
|
|
523
|
+
if (pieces.slice(i, j + 1).some((p) => INK.test(p.text))) {
|
|
524
|
+
if (i > 0) gaps[i] += pad;
|
|
525
|
+
if (j + 1 < pieces.length) gaps[j + 1] += pad;
|
|
526
|
+
}
|
|
527
|
+
i = j;
|
|
528
|
+
}
|
|
529
|
+
return gaps;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const resolveAlign = (align: SafeTextStyle['align'], rtl: boolean): 'left' | 'center' | 'right' =>
|
|
533
|
+
align === 'center' ? 'center' : (align === 'start') !== rtl ? 'left' : 'right';
|
|
534
|
+
|
|
535
|
+
const metric = (v: number | undefined, fallback: number) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback);
|
|
536
|
+
|
|
537
|
+
/** Colour of highlighted words: the highlight colour, or for markers a colour readable on the pill. */
|
|
538
|
+
export function highlightTextColor(style: SafeTextStyle): string {
|
|
539
|
+
if (style.highlightMode === 'color') return rgba(style.highlight);
|
|
540
|
+
if (contrast(style.highlight, style.color) >= 3) return rgba(style.color);
|
|
541
|
+
const white = '#ffffff';
|
|
542
|
+
const ink = '#111114';
|
|
543
|
+
return rgba(contrast(style.highlight, white) >= contrast(style.highlight, ink) ? white : ink);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export interface DrawTextOptions {
|
|
547
|
+
/** u in pixels; shadow sizes are fractions of it. */
|
|
548
|
+
unit: number;
|
|
549
|
+
/** Device pixels per user unit (shadows ignore the transform). */
|
|
550
|
+
pxScale?: number;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Draws a laid-out block centred on the origin: the box spans
|
|
555
|
+
* [-boxWidth/2, boxWidth/2] x [-height/2, height/2].
|
|
556
|
+
*/
|
|
557
|
+
export function drawTextBlock(ctx: Ctx2D, layout: LaidText, opts: DrawTextOptions): void {
|
|
558
|
+
if (!layout.lines.some((l) => l.runs.length > 0)) return;
|
|
559
|
+
const { style } = layout;
|
|
560
|
+
const px = layout.fontPx;
|
|
561
|
+
const unit = num(opts.unit, 1);
|
|
562
|
+
const pxScale = num(opts.pxScale, 1);
|
|
563
|
+
const native = layout.nativeSpacing && 'letterSpacing' in ctx;
|
|
564
|
+
ctx.save();
|
|
565
|
+
try {
|
|
566
|
+
ctx.font = layout.font;
|
|
567
|
+
ctx.textAlign = 'left';
|
|
568
|
+
ctx.textBaseline = 'alphabetic';
|
|
569
|
+
ctx.direction = 'ltr';
|
|
570
|
+
// Font-level metrics (not the glyphs of each line) so every line box is placed alike,
|
|
571
|
+
// and lines are centred in their line height the way CSS distributes half-leading.
|
|
572
|
+
const hg = ctx.measureText('Hg');
|
|
573
|
+
const ascent = metric(hg.fontBoundingBoxAscent, px * 0.8);
|
|
574
|
+
const descent = metric(hg.fontBoundingBoxDescent, px * 0.2);
|
|
575
|
+
const lh = layout.lineHeightPx;
|
|
576
|
+
const top = -layout.height / 2;
|
|
577
|
+
const half = layout.boxWidth / 2;
|
|
578
|
+
const align = resolveAlign(style.align, layout.rtl);
|
|
579
|
+
const pad = style.highlightMode === 'marker' ? PILL_PAD_X * px : 0;
|
|
580
|
+
|
|
581
|
+
const placed: Placed[] = [];
|
|
582
|
+
layout.lines.forEach((line, i) => {
|
|
583
|
+
if (!line.runs.length) return;
|
|
584
|
+
const baseline = top + i * lh + (lh - (ascent + descent)) / 2 + ascent;
|
|
585
|
+
const pieces = linePieces(ctx, line, native, layout.lang);
|
|
586
|
+
const gaps = pillGaps(pieces, pad);
|
|
587
|
+
let total = 0;
|
|
588
|
+
for (let k = 0; k < pieces.length; k++) total += gaps[k] + pieces[k].width;
|
|
589
|
+
// Spacing follows every glyph, including the last; centre on the ink, not on that gap.
|
|
590
|
+
const visual = total - (line.spacing !== 0 ? line.spacing : 0);
|
|
591
|
+
let x = align === 'left' ? -half : align === 'right' ? half - visual : -visual / 2;
|
|
592
|
+
pieces.forEach((p, k) => {
|
|
593
|
+
x += gaps[k];
|
|
594
|
+
placed.push({ ...p, x, baseline, line: i });
|
|
595
|
+
x += p.width;
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
if (style.highlightMode === 'marker') drawPills(ctx, placed, layout, native);
|
|
600
|
+
|
|
601
|
+
if (style.shadow) {
|
|
602
|
+
ctx.shadowColor = rgba(style.shadow.color);
|
|
603
|
+
ctx.shadowBlur = style.shadow.blur * unit * pxScale;
|
|
604
|
+
ctx.shadowOffsetX = style.shadow.x * unit * pxScale;
|
|
605
|
+
ctx.shadowOffsetY = style.shadow.y * unit * pxScale;
|
|
606
|
+
}
|
|
607
|
+
const base = rgba(style.color);
|
|
608
|
+
const hi = highlightTextColor(style);
|
|
609
|
+
for (const p of placed) {
|
|
610
|
+
ctx.fillStyle = p.highlighted ? hi : base;
|
|
611
|
+
ctx.direction = p.rtl ? 'rtl' : 'ltr';
|
|
612
|
+
if (p.spacing !== 0 && !native) {
|
|
613
|
+
drawSpaced(ctx, p, layout.lang);
|
|
614
|
+
} else {
|
|
615
|
+
if (native) setSpacing(ctx, p.spacing);
|
|
616
|
+
ctx.fillText(p.text, p.x, p.baseline);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
} finally {
|
|
620
|
+
if ('letterSpacing' in ctx) setSpacing(ctx, 0);
|
|
621
|
+
ctx.restore();
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Per-grapheme drawing for engines without ctx.letterSpacing (older WebKit). */
|
|
626
|
+
function drawSpaced(ctx: Ctx2D, p: Placed, lang: string): void {
|
|
627
|
+
const gs = graphemes(p.text, lang);
|
|
628
|
+
if (p.rtl) gs.reverse();
|
|
629
|
+
let x = p.x;
|
|
630
|
+
for (const g of gs) {
|
|
631
|
+
ctx.fillText(g, x, p.baseline);
|
|
632
|
+
x += finiteWidth(ctx.measureText(g).width) + p.spacing;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const LEADING_WS = /^\s+/;
|
|
637
|
+
const TRAILING_WS = /\s+$/;
|
|
638
|
+
|
|
639
|
+
function drawPills(ctx: Ctx2D, placed: Placed[], layout: LaidText, native: boolean): void {
|
|
640
|
+
const px = layout.fontPx;
|
|
641
|
+
const padX = PILL_PAD_X * px;
|
|
642
|
+
const padY = PILL_PAD_Y * px;
|
|
643
|
+
ctx.direction = 'ltr';
|
|
644
|
+
if (native) setSpacing(ctx, 0);
|
|
645
|
+
// Pills hug ascenders and descenders of the font, grown to the actual glyphs of the
|
|
646
|
+
// line so tall scripts (Arabic hamza, Vietnamese stacks) stay inside.
|
|
647
|
+
const probeTop = metric(ctx.measureText('Hd').actualBoundingBoxAscent, px * 0.75);
|
|
648
|
+
const probeBottom = metric(ctx.measureText('gy').actualBoundingBoxDescent, px * 0.22);
|
|
649
|
+
const lineExtent = new Map<number, [number, number]>();
|
|
650
|
+
const extentOf = (line: number): [number, number] => {
|
|
651
|
+
let e = lineExtent.get(line);
|
|
652
|
+
if (!e) {
|
|
653
|
+
const m = ctx.measureText(layout.lines[line].runs.map((r) => r.text).join(''));
|
|
654
|
+
e = [Math.max(probeTop, metric(m.actualBoundingBoxAscent, 0)), Math.max(probeBottom, metric(m.actualBoundingBoxDescent, 0))];
|
|
655
|
+
lineExtent.set(line, e);
|
|
656
|
+
}
|
|
657
|
+
return e;
|
|
658
|
+
};
|
|
659
|
+
const wsWidth = (p: Placed, side: 'left' | 'right'): number => {
|
|
660
|
+
const re = (side === 'left') !== p.rtl ? LEADING_WS : TRAILING_WS;
|
|
661
|
+
const ws = re.exec(p.text)?.[0];
|
|
662
|
+
if (!ws) return 0;
|
|
663
|
+
if (ws.length === p.text.length) return p.width;
|
|
664
|
+
if (native) setSpacing(ctx, p.spacing);
|
|
665
|
+
const w = measureWith(ctx, ws, p.spacing, native, layout.lang);
|
|
666
|
+
if (native) setSpacing(ctx, 0);
|
|
667
|
+
return w;
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
ctx.fillStyle = rgba(layout.style.highlight);
|
|
671
|
+
for (let i = 0; i < placed.length; ) {
|
|
672
|
+
if (!placed[i].highlighted) {
|
|
673
|
+
i++;
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
// One pill per visually contiguous highlighted stretch (a run cut by bidi stays one pill).
|
|
677
|
+
let j = i;
|
|
678
|
+
while (j + 1 < placed.length && placed[j + 1].highlighted && placed[j + 1].line === placed[i].line) j++;
|
|
679
|
+
const first = placed[i];
|
|
680
|
+
const last = placed[j];
|
|
681
|
+
const left = first.x + wsWidth(first, 'left');
|
|
682
|
+
// The spacing after the last glyph is part of the measured width but not of the ink.
|
|
683
|
+
const right = last.x + last.width - wsWidth(last, 'right') - (last.rtl ? 0 : last.spacing);
|
|
684
|
+
if (right - left > 0.5) {
|
|
685
|
+
const [up, down] = extentOf(first.line);
|
|
686
|
+
const y0 = first.baseline - up - padY;
|
|
687
|
+
const h = up + down + 2 * padY;
|
|
688
|
+
ctx.beginPath();
|
|
689
|
+
roundedRect(ctx, left - padX, y0, right - left + 2 * padX, h, Math.min(0.22 * px, h / 2));
|
|
690
|
+
ctx.fill();
|
|
691
|
+
}
|
|
692
|
+
i = j + 1;
|
|
693
|
+
}
|
|
694
|
+
}
|