@zseven-w/dsh-openpencil-linux-arm64 0.1.0-rc.4
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/THIRD_PARTY_NOTICES.md +72 -0
- package/bin/op-host-web-server +0 -0
- package/openpencil-runtime.json +211 -0
- package/package.json +34 -0
- package/web/canvaskit/LICENSE +29 -0
- package/web/canvaskit/README.md +21 -0
- package/web/canvaskit/canvaskit.js +288 -0
- package/web/canvaskit/canvaskit.wasm +0 -0
- package/web/pkg/assets/iconify-catalog-core.json +1 -0
- package/web/pkg/assets/prompt_center_previews/component-data-grid.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/component-form-lab.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/dashboard-pulse.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/dashboard-sentinel.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/extreme-calendar.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/extreme-calm.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/extreme-daily-app.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/extreme-now-playing.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/extreme-weather.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/freeform-aloft.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/freeform-bloom.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/freeform-companion.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/freeform-daybreak.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/freeform-ember.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/freeform-forage.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/freeform-gallery.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/freeform-hearth.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/freeform-lingua.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/freeform-marginalia.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/freeform-marquee.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/freeform-meteo.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/freeform-nightcap.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/freeform-nocturne.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/freeform-relic.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/freeform-ritual.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/freeform-still.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/freeform-verdant.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/freeform-volt.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/freeform-wander.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/gallery-aloft.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/gallery-bloom.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/gallery-companion.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/gallery-daybreak.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/gallery-ember.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/gallery-forage.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/gallery-gallery.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/gallery-hearth.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/gallery-lingua.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/gallery-marginalia.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/gallery-marquee.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/gallery-meteo.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/gallery-nightcap.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/gallery-nocturne.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/gallery-relic.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/gallery-ritual.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/gallery-still.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/gallery-verdant.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/gallery-volt.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/gallery-wander.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/modify-complete-states.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/modify-polish-current.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/starter-barbershop.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/starter-coffee-shop.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/starter-dashboard.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/starter-travel-app.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/web-atelier.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/web-kilnform.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/web-orbit.jpg +0 -0
- package/web/pkg/assets/prompt_center_previews/web-reefwright.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/app-onboarding-triptych.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/banxin-rule-deck.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/before-after.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/bookreview-silk-carousel.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/brand-concept-sheet.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/challenge-tracker-infographic.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/cityguide-film-carousel.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/concept-contrast-infographic.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/daily-sign-card.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/data-report-infographic.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/data-story-infographic.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/datareport-grid-carousel.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/diy-blueprint-guide.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/do-dont-comparison.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/dossier-linen-deck.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/ecosystem-map-infographic.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/event-poster-deck.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/faq-thread-infographic.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/gradient-tech.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/gridpaper-graphite-deck.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/journal-checklist-card.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/knowledge-card-square.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/knowledge-card-vertical.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/knowledge-carousel.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/lecture-deck-light.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/ledger-tick-deck.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/logo-qa-board.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/metric-single-card.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/milestone-timeline-infographic.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/minimal-keynote.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/myth-truth-comparison.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/notice-board-card.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/opinion-longform-carousel.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/photo-composition-tutorial.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/pitch-deck-dark.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/pitfall-list-infographic.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/price-tier-card.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/pricing-tiers-comparison.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/product-landing-light.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/punch-quote-card.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/qa-chalkboard-carousel.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/quote-frame-card.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/ranking-board-infographic.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/recipe-four-step.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/saas-landing-orange.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/scenario-guide-comparison.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/screenshot-tutorial.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/skincare-routine-cards.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/slide-deck.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/software-step-tutorial.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/sounding-navy-deck.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/spec-table-comparison.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/spine-culture-card.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/steps-flow-infographic.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/storage-makeover-steps.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/story-night-carousel.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/three-way-comparison.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/tidemark-slate-deck.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/time-shift-comparison.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/toolkit-notebook-carousel.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/tradeoff-scale-comparison.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/tutorial-journal-carousel.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/version-diff-comparison.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/weekly-report-lesson.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/workout-breakdown-guide.jpg +0 -0
- package/web/pkg/assets/scene_template_previews/yearreview-mineral-carousel.jpg +0 -0
- package/web/pkg/assets/scene_templates/app-onboarding-triptych.op +1087 -0
- package/web/pkg/assets/scene_templates/banxin-rule-deck.op +2702 -0
- package/web/pkg/assets/scene_templates/before-after.op +753 -0
- package/web/pkg/assets/scene_templates/bookreview-silk-carousel.op +3272 -0
- package/web/pkg/assets/scene_templates/brand-concept-sheet.op +1 -0
- package/web/pkg/assets/scene_templates/challenge-tracker-infographic.op +2448 -0
- package/web/pkg/assets/scene_templates/cityguide-film-carousel.op +13691 -0
- package/web/pkg/assets/scene_templates/concept-contrast-infographic.op +1433 -0
- package/web/pkg/assets/scene_templates/daily-sign-card.op +370 -0
- package/web/pkg/assets/scene_templates/data-report-infographic.op +1946 -0
- package/web/pkg/assets/scene_templates/data-story-infographic.op +1916 -0
- package/web/pkg/assets/scene_templates/datareport-grid-carousel.op +2799 -0
- package/web/pkg/assets/scene_templates/diy-blueprint-guide.op +2744 -0
- package/web/pkg/assets/scene_templates/do-dont-comparison.op +1170 -0
- package/web/pkg/assets/scene_templates/dossier-linen-deck.op +7045 -0
- package/web/pkg/assets/scene_templates/ecosystem-map-infographic.op +2015 -0
- package/web/pkg/assets/scene_templates/event-poster-deck.op +2756 -0
- package/web/pkg/assets/scene_templates/faq-thread-infographic.op +1698 -0
- package/web/pkg/assets/scene_templates/gradient-tech.op +3322 -0
- package/web/pkg/assets/scene_templates/gridpaper-graphite-deck.op +11996 -0
- package/web/pkg/assets/scene_templates/journal-checklist-card.op +764 -0
- package/web/pkg/assets/scene_templates/knowledge-card-square.op +560 -0
- package/web/pkg/assets/scene_templates/knowledge-card-vertical.op +719 -0
- package/web/pkg/assets/scene_templates/knowledge-carousel.op +1640 -0
- package/web/pkg/assets/scene_templates/lecture-deck-light.op +5211 -0
- package/web/pkg/assets/scene_templates/ledger-tick-deck.op +6756 -0
- package/web/pkg/assets/scene_templates/logo-qa-board.op +1 -0
- package/web/pkg/assets/scene_templates/metric-single-card.op +259 -0
- package/web/pkg/assets/scene_templates/milestone-timeline-infographic.op +2116 -0
- package/web/pkg/assets/scene_templates/minimal-keynote.op +1085 -0
- package/web/pkg/assets/scene_templates/myth-truth-comparison.op +1315 -0
- package/web/pkg/assets/scene_templates/notice-board-card.op +671 -0
- package/web/pkg/assets/scene_templates/opinion-longform-carousel.op +2492 -0
- package/web/pkg/assets/scene_templates/photo-composition-tutorial.op +2558 -0
- package/web/pkg/assets/scene_templates/pitch-deck-dark.op +3020 -0
- package/web/pkg/assets/scene_templates/pitfall-list-infographic.op +1646 -0
- package/web/pkg/assets/scene_templates/price-tier-card.op +647 -0
- package/web/pkg/assets/scene_templates/pricing-tiers-comparison.op +1762 -0
- package/web/pkg/assets/scene_templates/product-landing-light.op +10717 -0
- package/web/pkg/assets/scene_templates/punch-quote-card.op +438 -0
- package/web/pkg/assets/scene_templates/qa-chalkboard-carousel.op +2568 -0
- package/web/pkg/assets/scene_templates/quote-frame-card.op +1233 -0
- package/web/pkg/assets/scene_templates/ranking-board-infographic.op +1945 -0
- package/web/pkg/assets/scene_templates/recipe-four-step.op +1177 -0
- package/web/pkg/assets/scene_templates/saas-landing-orange.op +6616 -0
- package/web/pkg/assets/scene_templates/scenario-guide-comparison.op +1721 -0
- package/web/pkg/assets/scene_templates/screenshot-tutorial.op +1 -0
- package/web/pkg/assets/scene_templates/skincare-routine-cards.op +3240 -0
- package/web/pkg/assets/scene_templates/slide-deck.op +2475 -0
- package/web/pkg/assets/scene_templates/software-step-tutorial.op +797 -0
- package/web/pkg/assets/scene_templates/sounding-navy-deck.op +4292 -0
- package/web/pkg/assets/scene_templates/spec-table-comparison.op +2033 -0
- package/web/pkg/assets/scene_templates/spine-culture-card.op +647 -0
- package/web/pkg/assets/scene_templates/steps-flow-infographic.op +1664 -0
- package/web/pkg/assets/scene_templates/storage-makeover-steps.op +2912 -0
- package/web/pkg/assets/scene_templates/story-night-carousel.op +2814 -0
- package/web/pkg/assets/scene_templates/three-way-comparison.op +1917 -0
- package/web/pkg/assets/scene_templates/tidemark-slate-deck.op +5716 -0
- package/web/pkg/assets/scene_templates/time-shift-comparison.op +1127 -0
- package/web/pkg/assets/scene_templates/toolkit-notebook-carousel.op +5335 -0
- package/web/pkg/assets/scene_templates/tradeoff-scale-comparison.op +995 -0
- package/web/pkg/assets/scene_templates/tutorial-journal-carousel.op +4425 -0
- package/web/pkg/assets/scene_templates/version-diff-comparison.op +1001 -0
- package/web/pkg/assets/scene_templates/weekly-report-lesson.op +2188 -0
- package/web/pkg/assets/scene_templates/workout-breakdown-guide.op +2579 -0
- package/web/pkg/assets/scene_templates/yearreview-mineral-carousel.op +4367 -0
- package/web/pkg/op_host_web.js +1971 -0
- package/web/pkg/op_host_web_bg.wasm +0 -0
- package/web/pkg/snippets/op-host-web-f712ff5933a5d456/src/figma_temp_worker.js +494 -0
- package/web/pkg/snippets/op-host-web-f712ff5933a5d456/src/op_ck_bridge.js +1712 -0
- package/web/pkg/snippets/op-host-web-f712ff5933a5d456/src/op_ck_image_cache.js +404 -0
|
@@ -0,0 +1,1712 @@
|
|
|
1
|
+
// Thin CanvasKit FFI bridge for the Rust web shell.
|
|
2
|
+
//
|
|
3
|
+
// FFI glue only — flat, scalar-argument drawing functions the Rust
|
|
4
|
+
// `CanvasKitBackend` calls through wasm-bindgen. All drawing / layout / widget
|
|
5
|
+
// logic lives in Rust; this file just maps those calls onto CanvasKit
|
|
6
|
+
// `SkCanvas` ops. Depends on the compiled CanvasKit artifact
|
|
7
|
+
// (`/canvaskit/canvaskit.{js,wasm}`), not on any TS source.
|
|
8
|
+
|
|
9
|
+
function loadScript(src) {
|
|
10
|
+
return new Promise((res, rej) => {
|
|
11
|
+
if (window.CanvasKitInit) return res();
|
|
12
|
+
const s = document.createElement('script');
|
|
13
|
+
s.src = src;
|
|
14
|
+
s.onload = res;
|
|
15
|
+
s.onerror = () => rej(new Error('failed to load ' + src));
|
|
16
|
+
document.head.appendChild(s);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function copyBytes(u8) {
|
|
21
|
+
return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let createWebImageCaches = null;
|
|
25
|
+
|
|
26
|
+
const TEXT_SCALE_STEPS_PER_OCTAVE = 4;
|
|
27
|
+
|
|
28
|
+
// Round UP to quarter-octave buckets (about 19% apart). This keeps cached
|
|
29
|
+
// browser-text bitmaps at or above the requested resolution while allowing
|
|
30
|
+
// adjacent continuous-zoom frames to reuse the same raster.
|
|
31
|
+
export function opCkQuantizeTextRasterScale(scale) {
|
|
32
|
+
const finiteScale = Number.isFinite(scale) && scale > 1 ? scale : 1;
|
|
33
|
+
const bucket = Math.ceil(
|
|
34
|
+
Math.log2(finiteScale) * TEXT_SCALE_STEPS_PER_OCTAVE - 1e-9,
|
|
35
|
+
);
|
|
36
|
+
let quantized = 2 ** (bucket / TEXT_SCALE_STEPS_PER_OCTAVE);
|
|
37
|
+
if (quantized < finiteScale) {
|
|
38
|
+
quantized *= 2 ** (1 / TEXT_SCALE_STEPS_PER_OCTAVE);
|
|
39
|
+
}
|
|
40
|
+
return quantized;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function setImageCacheFactory(factory) {
|
|
44
|
+
createWebImageCaches = factory;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const CSS_GENERIC_FONT_FAMILIES = new Set([
|
|
48
|
+
'serif',
|
|
49
|
+
'sans-serif',
|
|
50
|
+
'monospace',
|
|
51
|
+
'cursive',
|
|
52
|
+
'fantasy',
|
|
53
|
+
'system-ui',
|
|
54
|
+
'ui-serif',
|
|
55
|
+
'ui-sans-serif',
|
|
56
|
+
'ui-monospace',
|
|
57
|
+
'ui-rounded',
|
|
58
|
+
'math',
|
|
59
|
+
'emoji',
|
|
60
|
+
'fangsong',
|
|
61
|
+
'-apple-system',
|
|
62
|
+
'blinkmacsystemfont',
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
// Family matching is ASCII-case-insensitive. Deliberately avoid locale-aware
|
|
66
|
+
// case conversion: font registry keys must be stable in every browser locale.
|
|
67
|
+
export function opCkNormalizeFontFamilyName(family) {
|
|
68
|
+
return String(family || '').trim().replace(/[A-Z]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) + 32));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Parse a CSS font-family list without splitting commas contained in quoted
|
|
72
|
+
// family names. A backslash quotes the following character, which is enough
|
|
73
|
+
// for the escaped quotes and commas emitted by our HTML/Figma importers.
|
|
74
|
+
export function opCkParseFontFamilyStack(family) {
|
|
75
|
+
const candidates = [];
|
|
76
|
+
let current = '';
|
|
77
|
+
let quote = '';
|
|
78
|
+
let escaped = false;
|
|
79
|
+
const push = () => {
|
|
80
|
+
const candidate = current.trim();
|
|
81
|
+
if (candidate) candidates.push(candidate);
|
|
82
|
+
current = '';
|
|
83
|
+
};
|
|
84
|
+
for (const ch of String(family || '')) {
|
|
85
|
+
if (escaped) {
|
|
86
|
+
current += ch;
|
|
87
|
+
escaped = false;
|
|
88
|
+
} else if (ch === '\\') {
|
|
89
|
+
escaped = true;
|
|
90
|
+
} else if (quote) {
|
|
91
|
+
if (ch === quote) quote = '';
|
|
92
|
+
else current += ch;
|
|
93
|
+
} else if (ch === '"' || ch === "'") {
|
|
94
|
+
quote = ch;
|
|
95
|
+
} else if (ch === ',') {
|
|
96
|
+
push();
|
|
97
|
+
} else {
|
|
98
|
+
current += ch;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (escaped) current += '\\';
|
|
102
|
+
push();
|
|
103
|
+
return candidates;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Resolve registered families in authored CSS order. For one candidate an
|
|
107
|
+
// explicit browser import wins over the system face with the same name. A
|
|
108
|
+
// generic family is already available through the browser canvas path, so it
|
|
109
|
+
// terminates named-face lookup instead of skipping ahead in the stack.
|
|
110
|
+
export function opCkResolveRegisteredTypeface(family, importedTypefaces, systemTypefacesByFamily) {
|
|
111
|
+
for (const candidate of opCkParseFontFamilyStack(family)) {
|
|
112
|
+
const familyKey = opCkNormalizeFontFamilyName(candidate);
|
|
113
|
+
if (!familyKey) continue;
|
|
114
|
+
if (CSS_GENERIC_FONT_FAMILIES.has(familyKey)) return null;
|
|
115
|
+
const imported = importedTypefaces.get(familyKey);
|
|
116
|
+
if (imported) {
|
|
117
|
+
return { key: `imported:${familyKey}`, familyKey, source: 'imported', tf: imported.tf };
|
|
118
|
+
}
|
|
119
|
+
const system = systemTypefacesByFamily.get(familyKey);
|
|
120
|
+
if (system) {
|
|
121
|
+
return { key: `system:${familyKey}`, familyKey, source: 'system', tf: system.tf };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const opCkClampUnit = (value, fallback = 0) => {
|
|
128
|
+
const number = Number(value);
|
|
129
|
+
return Number.isFinite(number) ? Math.max(0, Math.min(1, number)) : fallback;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// Build the row-major vertex lattice consumed by CanvasKit.MakeVertices.
|
|
133
|
+
// Kept pure + exported so the geometry/index contract can run under Node
|
|
134
|
+
// without booting a WebGL surface. CanvasKit indices are u16, so grids larger
|
|
135
|
+
// than 65,535 vertices take the same visible first-colour fallback as malformed
|
|
136
|
+
// grids instead of wrapping indices and drawing corrupt triangles.
|
|
137
|
+
export function opCkBuildMeshGradientData(x, y, w, h, rows, cols, colors) {
|
|
138
|
+
rows = Math.trunc(Number(rows));
|
|
139
|
+
cols = Math.trunc(Number(cols));
|
|
140
|
+
const vertexCount = rows * cols;
|
|
141
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(w) || !Number.isFinite(h)
|
|
142
|
+
|| rows < 2 || cols < 2 || !Number.isSafeInteger(vertexCount)
|
|
143
|
+
|| vertexCount > 0xffff || colors.length !== vertexCount * 4) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const positions = new Float32Array(vertexCount * 2);
|
|
148
|
+
const rgba = new Float32Array(vertexCount * 4);
|
|
149
|
+
for (let row = 0; row < rows; row++) {
|
|
150
|
+
for (let colIndex = 0; colIndex < cols; colIndex++) {
|
|
151
|
+
const vertex = row * cols + colIndex;
|
|
152
|
+
positions[vertex * 2] = x + (colIndex / (cols - 1)) * w;
|
|
153
|
+
positions[vertex * 2 + 1] = y + (row / (rows - 1)) * h;
|
|
154
|
+
const colorOffset = vertex * 4;
|
|
155
|
+
rgba[colorOffset] = opCkClampUnit(colors[colorOffset]);
|
|
156
|
+
rgba[colorOffset + 1] = opCkClampUnit(colors[colorOffset + 1]);
|
|
157
|
+
rgba[colorOffset + 2] = opCkClampUnit(colors[colorOffset + 2]);
|
|
158
|
+
rgba[colorOffset + 3] = opCkClampUnit(colors[colorOffset + 3]);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const indices = new Uint16Array((rows - 1) * (cols - 1) * 6);
|
|
163
|
+
let index = 0;
|
|
164
|
+
for (let row = 0; row < rows - 1; row++) {
|
|
165
|
+
for (let colIndex = 0; colIndex < cols - 1; colIndex++) {
|
|
166
|
+
const topLeft = row * cols + colIndex;
|
|
167
|
+
const topRight = topLeft + 1;
|
|
168
|
+
const bottomLeft = (row + 1) * cols + colIndex;
|
|
169
|
+
const bottomRight = bottomLeft + 1;
|
|
170
|
+
indices.set([topLeft, topRight, bottomLeft, topRight, bottomRight, bottomLeft], index);
|
|
171
|
+
index += 6;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { positions, rgba, indices };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Initialise CanvasKit on `canvasId`. Returns a bridge object the Rust backend
|
|
178
|
+
// drives. Text is rasterized with browser/system fonts by default; the Rust
|
|
179
|
+
// side can additionally register Local Font Access faces through
|
|
180
|
+
// registerSystemFont().
|
|
181
|
+
export async function opCkInit(canvasId) {
|
|
182
|
+
await loadScript('/canvaskit/canvaskit.js');
|
|
183
|
+
const CK = await CanvasKitInit({ locateFile: (f) => '/canvaskit/' + f });
|
|
184
|
+
if (typeof createWebImageCaches !== 'function') {
|
|
185
|
+
throw new Error('CanvasKit image cache factory was not configured');
|
|
186
|
+
}
|
|
187
|
+
let surface = CK.MakeWebGLCanvasSurface(canvasId);
|
|
188
|
+
if (!surface) throw new Error('CanvasKit: MakeWebGLCanvasSurface returned null');
|
|
189
|
+
let canvas = surface.getCanvas();
|
|
190
|
+
const el = document.getElementById(canvasId);
|
|
191
|
+
|
|
192
|
+
const systemTypefaces = [];
|
|
193
|
+
const systemTypefaceKeys = new Set();
|
|
194
|
+
const systemTypefacesByFamily = new Map();
|
|
195
|
+
// User-imported font faces, keyed by normalized family name -> { tf, family }.
|
|
196
|
+
// A named family is a deliberate single-typeface choice (mirrors how native
|
|
197
|
+
// resolves a named family via FontMgr before any script fallback), so
|
|
198
|
+
// imported text shapes the WHOLE run with one typeface instead of
|
|
199
|
+
// re-segmenting by script.
|
|
200
|
+
const importedTypefaces = new Map();
|
|
201
|
+
const coverageCache = new Map();
|
|
202
|
+
// Per-CHARACTER coverage segments for explicitly selected family text,
|
|
203
|
+
// keyed on `(registry + family key, size, text)`. This used to
|
|
204
|
+
// rebuild a throwaway CK.Font + call getGlyphIDs over the whole string on
|
|
205
|
+
// EVERY drawText/measureTextFamilyStyled call, every frame — bounded FIFO
|
|
206
|
+
// cache like `browserTextCache` below (values are plain JS arrays, no
|
|
207
|
+
// wasm handles, so eviction is a plain `.delete()` on the Map).
|
|
208
|
+
const REGISTERED_COVERAGE_CACHE_CAP = 512;
|
|
209
|
+
const registeredCoverageCache = new Map();
|
|
210
|
+
// Per-(registry + family key, size) CK.Font instances shared by the
|
|
211
|
+
// family-aware draw + measure paths (`registeredFamilyFont`). Bounded,
|
|
212
|
+
// full-wipe eviction mirroring `svgPathCache` below (these ARE wasm
|
|
213
|
+
// handles and must be `.delete()`d before the Map entry is dropped).
|
|
214
|
+
const REGISTERED_FONT_CACHE_CAP = 512;
|
|
215
|
+
const registeredFamilyFontCache = new Map();
|
|
216
|
+
// These caches key off the selected registry identity. Any imported or
|
|
217
|
+
// system registry change invalidates them so later exact-name matches use
|
|
218
|
+
// the newly registered face.
|
|
219
|
+
const clearRegisteredFontCaches = () => {
|
|
220
|
+
registeredCoverageCache.clear();
|
|
221
|
+
for (const f of registeredFamilyFontCache.values()) f.delete();
|
|
222
|
+
registeredFamilyFontCache.clear();
|
|
223
|
+
};
|
|
224
|
+
const browserTextCanvas = document.createElement('canvas');
|
|
225
|
+
const browserTextCtx = browserTextCanvas.getContext('2d', { willReadFrequently: true });
|
|
226
|
+
const browserTextCache = new Map();
|
|
227
|
+
const clearBrowserTextCache = () => {
|
|
228
|
+
for (const entry of browserTextCache.values()) {
|
|
229
|
+
if (entry && entry.image && entry.image.delete) entry.image.delete();
|
|
230
|
+
}
|
|
231
|
+
browserTextCache.clear();
|
|
232
|
+
};
|
|
233
|
+
// Retain DPR so display changes can immediately invalidate stale handles;
|
|
234
|
+
// per-draw supersampling comes from the current CanvasKit transform below.
|
|
235
|
+
let textDpr = 1;
|
|
236
|
+
const browserTextFontStack = [
|
|
237
|
+
'-apple-system',
|
|
238
|
+
'BlinkMacSystemFont',
|
|
239
|
+
'"Segoe UI"',
|
|
240
|
+
'"Helvetica Neue"',
|
|
241
|
+
'Arial',
|
|
242
|
+
'"Apple Color Emoji"',
|
|
243
|
+
'"Segoe UI Emoji"',
|
|
244
|
+
'"Noto Color Emoji"',
|
|
245
|
+
'"PingFang SC"',
|
|
246
|
+
'"PingFang TC"',
|
|
247
|
+
'"Hiragino Sans"',
|
|
248
|
+
'"Hiragino Kaku Gothic ProN"',
|
|
249
|
+
'"Yu Gothic"',
|
|
250
|
+
'"Yu Mincho"',
|
|
251
|
+
'"Meiryo"',
|
|
252
|
+
'"Apple SD Gothic Neo"',
|
|
253
|
+
'"Malgun Gothic"',
|
|
254
|
+
'"Noto Sans CJK SC"',
|
|
255
|
+
'"Noto Sans CJK TC"',
|
|
256
|
+
'"Noto Sans CJK JP"',
|
|
257
|
+
'"Noto Sans CJK KR"',
|
|
258
|
+
'"Kohinoor Devanagari"',
|
|
259
|
+
'"Devanagari Sangam MN"',
|
|
260
|
+
'"ITF Devanagari"',
|
|
261
|
+
'"ITFDevanagari"',
|
|
262
|
+
'"Mukta Mahee"',
|
|
263
|
+
'"MuktaMahee"',
|
|
264
|
+
'"Noto Sans Devanagari"',
|
|
265
|
+
'"Nirmala UI"',
|
|
266
|
+
'"Mangal"',
|
|
267
|
+
'"SF Hebrew"',
|
|
268
|
+
'"SFHebrew"',
|
|
269
|
+
'"Arial Hebrew"',
|
|
270
|
+
'"Geeza Pro"',
|
|
271
|
+
'"Al Nile"',
|
|
272
|
+
'"SF Georgian"',
|
|
273
|
+
'"SFGeorgian"',
|
|
274
|
+
'"Thonburi"',
|
|
275
|
+
'"Sukhumvit Set"',
|
|
276
|
+
'"SukhumvitSet"',
|
|
277
|
+
'"Noto Sans Thai"',
|
|
278
|
+
'"Noto Sans Thai UI"',
|
|
279
|
+
'"Leelawadee UI"',
|
|
280
|
+
'"Arial Unicode MS"',
|
|
281
|
+
'"Noto Sans"',
|
|
282
|
+
'sans-serif',
|
|
283
|
+
].join(', ');
|
|
284
|
+
|
|
285
|
+
// CJK range check (Han / Hiragana / Katakana / Hangul / fullwidth).
|
|
286
|
+
const hasCjk = (t) => { for (const ch of t) { const c = ch.codePointAt(0); if ((c >= 0x2e80 && c <= 0x9fff) || (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xff00 && c <= 0xffef) || (c >= 0x3000 && c <= 0x30ff)) return true; } return false; };
|
|
287
|
+
const hasSystemFallbackText = (t) => {
|
|
288
|
+
for (const ch of t) {
|
|
289
|
+
const c = ch.codePointAt(0);
|
|
290
|
+
if (c > 0x7f) return true;
|
|
291
|
+
}
|
|
292
|
+
return false;
|
|
293
|
+
};
|
|
294
|
+
const normalizedFamily = opCkNormalizeFontFamilyName;
|
|
295
|
+
const familyIncludes = (family, parts) => {
|
|
296
|
+
const f = normalizedFamily(family);
|
|
297
|
+
return parts.some((part) => f.includes(part.toLowerCase()));
|
|
298
|
+
};
|
|
299
|
+
const isEmojiFamily = (family) => {
|
|
300
|
+
const f = normalizedFamily(family);
|
|
301
|
+
return f.includes('emoji') || f.includes('color symbol');
|
|
302
|
+
};
|
|
303
|
+
const isCjkFamily = (family) => {
|
|
304
|
+
return familyIncludes(family, ['PingFang', 'Hiragino Sans', 'Hiragino Kaku Gothic', 'Heiti', 'STHeiti', 'Songti', 'Noto Sans CJK', 'Noto Sans SC', 'Noto Sans TC', 'Noto Sans JP', 'Noto Sans KR', 'Source Han Sans', 'Microsoft YaHei', 'Microsoft JhengHei', 'SimHei', 'SimSun', 'Yu Gothic', 'Yu Mincho', 'Meiryo', 'Malgun Gothic', 'AppleGothic', 'Nanum Gothic', 'Apple SD Gothic Neo', 'Arial Unicode MS']);
|
|
305
|
+
};
|
|
306
|
+
const isTextFallbackFamily = (family) => {
|
|
307
|
+
return isEmojiFamily(family) || isCjkFamily(family) || familyIncludes(family, ['Arial', 'Arial Unicode MS', 'Helvetica Neue', 'SF Pro', '.SF NS', 'Segoe UI', 'Segoe UI Historic', 'Segoe UI Symbol', 'Apple Symbols', 'Noto Sans', 'Kohinoor Devanagari', 'Devanagari Sangam MN', 'ITFDevanagari', 'ITF Devanagari', 'MuktaMahee', 'Mukta Mahee', 'Noto Sans Devanagari', 'Nirmala UI', 'Mangal', 'SFGeorgian', 'SF Georgian', 'SFHebrew', 'SF Hebrew', 'Thonburi', 'Sukhumvit', 'Noto Sans Thai', 'Leelawadee UI']);
|
|
308
|
+
};
|
|
309
|
+
// Emoji codepoint (pictographs / symbols / dingbats / regional) + attaching
|
|
310
|
+
// modifiers (variation selector, ZWJ, keycap, skin tone) that extend a run.
|
|
311
|
+
const isEmojiCp = (c) => (c >= 0x1f000 && c <= 0x1faff) || (c >= 0x2600 && c <= 0x27bf) || (c >= 0x2b00 && c <= 0x2bff) || (c >= 0x1f1e6 && c <= 0x1f1ff);
|
|
312
|
+
const isEmojiMod = (c) => (c >= 0xfe00 && c <= 0xfe0f) || c === 0x200d || c === 0x20e3 || (c >= 0x1f3fb && c <= 0x1f3ff);
|
|
313
|
+
// Split text into consecutive {text, emoji} runs so each draws with the right
|
|
314
|
+
// typeface (CanvasKit drawText is single-typeface, no per-glyph fallback).
|
|
315
|
+
const segments = (t) => {
|
|
316
|
+
const out = []; let cur = '', curEmoji = null;
|
|
317
|
+
for (const ch of t) {
|
|
318
|
+
const c = ch.codePointAt(0);
|
|
319
|
+
const e = isEmojiMod(c) ? (curEmoji === null ? false : curEmoji) : isEmojiCp(c);
|
|
320
|
+
if (curEmoji === null) { curEmoji = e; cur = ch; }
|
|
321
|
+
else if (e === curEmoji) { cur += ch; }
|
|
322
|
+
else { out.push({ text: cur, emoji: curEmoji }); cur = ch; curEmoji = e; }
|
|
323
|
+
}
|
|
324
|
+
if (cur) out.push({ text: cur, emoji: curEmoji });
|
|
325
|
+
return out;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const col = (r, g, b, a) => CK.Color4f(r, g, b, a);
|
|
329
|
+
const isPaintStyle = (style) => Boolean(style && typeof style.value !== 'undefined');
|
|
330
|
+
const setPaintStyle = (paint, style) => { if (isPaintStyle(style)) paint.setStyle(style); };
|
|
331
|
+
// Long-lived fill/stroke paints reused across every plain-color primitive
|
|
332
|
+
// draw (fillRect, fillRRect, ovals, lines, polygons, SVG path fills) —
|
|
333
|
+
// a full-editor repaint issued hundreds of `new CK.Paint()` + `.delete()`
|
|
334
|
+
// pairs per frame otherwise. Every `fillPaint`/`strokePaint` call resets
|
|
335
|
+
// ALL mutable properties (color, antialias, style, stroke width/cap/join)
|
|
336
|
+
// it ever sets, so no state leaks between unrelated call sites even though
|
|
337
|
+
// a couple of callers (drawText) further mutate the returned paint (e.g.
|
|
338
|
+
// StrokeAndFill for bold synth) after fetching it — the next fetch wipes
|
|
339
|
+
// that back to a clean Fill/Stroke paint. Callers must NOT `.delete()`
|
|
340
|
+
// these two objects. One-off-effect paints (shaders, blend modes, mask
|
|
341
|
+
// filters/blur) are excluded from this cache by design — see
|
|
342
|
+
// `shaderPaint` and `fillInnerShadowSvgPath`'s `cut` paint.
|
|
343
|
+
const cachedFillPaint = new CK.Paint();
|
|
344
|
+
const cachedStrokePaint = new CK.Paint();
|
|
345
|
+
const fillPaint = (r, g, b, a) => { const p = cachedFillPaint; p.setColor(col(r, g, b, a)); p.setAntiAlias(true); setPaintStyle(p, CK.PaintStyle.Fill); return p; };
|
|
346
|
+
const strokePaint = (r, g, b, a, w) => { const p = cachedStrokePaint; p.setColor(col(r, g, b, a)); p.setAntiAlias(true); setPaintStyle(p, CK.PaintStyle.Stroke); p.setStrokeWidth(w); p.setStrokeCap(CK.StrokeCap.Round); p.setStrokeJoin(CK.StrokeJoin.Round); return p; };
|
|
347
|
+
// Dedicated (allocated + `.delete()`d) fill paint for the one call site
|
|
348
|
+
// that needs a SECOND live fill paint while `drawText`'s cached `p` (see
|
|
349
|
+
// above) is already in scope: `drawScriptRun` is invoked both standalone
|
|
350
|
+
// AND nested inside `drawText`'s per-segment loop, so it cannot safely
|
|
351
|
+
// share the single cached fill paint without the outer and inner draws
|
|
352
|
+
// fighting over the same object mid-loop. Kept as a plain allocation
|
|
353
|
+
// rather than a second cache since text draws are far less frequent than
|
|
354
|
+
// the primitive-shape hot path this task targets.
|
|
355
|
+
const allocFillPaint = (r, g, b, a) => { const p = new CK.Paint(); p.setColor(col(r, g, b, a)); p.setAntiAlias(true); setPaintStyle(p, CK.PaintStyle.Fill); return p; };
|
|
356
|
+
// Quote the authored family so names with spaces stay one CSS token, and
|
|
357
|
+
// strip quote/backslash so a family name can't break out of the shorthand.
|
|
358
|
+
// Only families the BROWSER knows resolve here; faces registered into
|
|
359
|
+
// CanvasKit as raw bytes are invisible to CSS and fall through the stack.
|
|
360
|
+
const cssFamilyToken = (family) => {
|
|
361
|
+
const name = String(family || '').trim().replace(/["\\]/g, '');
|
|
362
|
+
return name ? `"${name}", ` : '';
|
|
363
|
+
};
|
|
364
|
+
const browserTextFont = (sz, weight, italic, family) => `${italic ? 'italic ' : ''}${Math.max(100, Math.min(900, Math.round(weight || 400)))} ${Math.max(1, sz)}px ${cssFamilyToken(family)}${browserTextFontStack}`;
|
|
365
|
+
const shouldUseBrowserTextFallback = (_t, _emojiRun) => Boolean(browserTextCtx);
|
|
366
|
+
// Complex scripts (Arabic and friends — see `op_editor_core::text_script`,
|
|
367
|
+
// which owns the predicate for BOTH hosts). CanvasKit's `canvas.drawText`
|
|
368
|
+
// is a 1:1 cmap lookup: no bidi reordering, no contextual joining, so it
|
|
369
|
+
// paints Arabic in storage order with isolated letterforms. The browser's
|
|
370
|
+
// own 2D text engine does both correctly and `drawBrowserText` already
|
|
371
|
+
// wraps it, so complex runs are sent there instead.
|
|
372
|
+
//
|
|
373
|
+
// They must go WHOLE. `drawScriptRun` splits a run into script segments and
|
|
374
|
+
// advances left to right, so it would resolve bidi within each segment and
|
|
375
|
+
// then lay the segments out in storage order — a mixed digit/Arabic run
|
|
376
|
+
// like "50 ك.م" would still come out wrong.
|
|
377
|
+
const shapedTextUnavailable = () => !browserTextCtx;
|
|
378
|
+
const allSegmentsUseBrowserTextFallback = (segs) => segs.length > 0 && segs.every((seg) => shouldUseBrowserTextFallback(seg.text, seg.emoji));
|
|
379
|
+
const browserTextMeasure = (t, sz, weight = 400, italic = false, family = '') => {
|
|
380
|
+
if (!browserTextCtx) return 0;
|
|
381
|
+
browserTextCtx.font = browserTextFont(sz, weight, italic, family);
|
|
382
|
+
return browserTextCtx.measureText(t).width;
|
|
383
|
+
};
|
|
384
|
+
const effectiveTextScale = () => {
|
|
385
|
+
const m = canvas.getTotalMatrix();
|
|
386
|
+
const xScale = Math.hypot(m[0], m[3]);
|
|
387
|
+
const yScale = Math.hypot(m[1], m[4]);
|
|
388
|
+
return Math.max(1, xScale, yScale);
|
|
389
|
+
};
|
|
390
|
+
// Continuous zoom used to put the exact floating-point transform in every
|
|
391
|
+
// browser-text cache key. Quantization lets adjacent frames reuse bitmaps.
|
|
392
|
+
const browserTextRasterScale = () =>
|
|
393
|
+
opCkQuantizeTextRasterScale(effectiveTextScale());
|
|
394
|
+
// Text uses a white coverage mask keyed without colour/alpha, then receives
|
|
395
|
+
// a SrcIn tint at draw time so differently coloured runs share a bitmap.
|
|
396
|
+
// Emoji retain their legacy RGBA-keyed, untinted raster path because colour
|
|
397
|
+
// glyphs can ignore fillStyle. Cache-hit reinsertion keeps eviction LRU.
|
|
398
|
+
const browserTextImage = (t, sz, weight, italic, emoji, r, g, b, a, family = '') => {
|
|
399
|
+
if (!browserTextCtx) return null;
|
|
400
|
+
const ss = browserTextRasterScale();
|
|
401
|
+
// `family` participates in the key: the same string shaped in two
|
|
402
|
+
// different families is two different bitmaps.
|
|
403
|
+
const key = emoji
|
|
404
|
+
? ['e', t, sz, weight, italic ? 1 : 0, r, g, b, a, ss, family].join('\n')
|
|
405
|
+
: [t, sz, weight, italic ? 1 : 0, ss, family].join('\n');
|
|
406
|
+
const hit = browserTextCache.get(key);
|
|
407
|
+
if (hit) {
|
|
408
|
+
browserTextCache.delete(key);
|
|
409
|
+
browserTextCache.set(key, hit);
|
|
410
|
+
return hit;
|
|
411
|
+
}
|
|
412
|
+
const font = browserTextFont(sz, weight, italic, family);
|
|
413
|
+
browserTextCtx.font = font;
|
|
414
|
+
const metrics = browserTextCtx.measureText(t);
|
|
415
|
+
// Logical (CSS-px) box the glyphs occupy; positioning stays in CSS units.
|
|
416
|
+
const width = Math.max(1, Math.ceil(metrics.width + 4));
|
|
417
|
+
const ascent = Math.ceil(metrics.actualBoundingBoxAscent || sz * 0.8);
|
|
418
|
+
const descent = Math.ceil(metrics.actualBoundingBoxDescent || sz * 0.25);
|
|
419
|
+
const baseline = ascent + 2;
|
|
420
|
+
const height = Math.max(1, baseline + descent + 2);
|
|
421
|
+
// Back the offscreen canvas with `ss`x pixels and scale drawing so the
|
|
422
|
+
// rasterized glyphs carry device-resolution detail. Setting canvas.width/
|
|
423
|
+
// height resets the 2D context, so (re)apply font/baseline/fill + the
|
|
424
|
+
// supersample transform here.
|
|
425
|
+
browserTextCanvas.width = Math.max(1, Math.ceil(width * ss));
|
|
426
|
+
browserTextCanvas.height = Math.max(1, Math.ceil(height * ss));
|
|
427
|
+
browserTextCtx.setTransform(ss, 0, 0, ss, 0, 0);
|
|
428
|
+
browserTextCtx.clearRect(0, 0, width, height);
|
|
429
|
+
browserTextCtx.font = font;
|
|
430
|
+
browserTextCtx.textBaseline = 'alphabetic';
|
|
431
|
+
// Emoji bake their colour (legacy path); text rasters a WHITE mask that is
|
|
432
|
+
// tinted at draw time.
|
|
433
|
+
browserTextCtx.fillStyle = emoji
|
|
434
|
+
? `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${a})`
|
|
435
|
+
: 'rgba(255, 255, 255, 1)';
|
|
436
|
+
browserTextCtx.fillText(t, 2, baseline);
|
|
437
|
+
browserTextCtx.setTransform(1, 0, 0, 1, 0, 0);
|
|
438
|
+
const image = CK.MakeImageFromCanvasImageSource(browserTextCanvas);
|
|
439
|
+
if (!image) return null;
|
|
440
|
+
const entry = { image, width: metrics.width, baseline, ss, emoji: Boolean(emoji) };
|
|
441
|
+
browserTextCache.set(key, entry);
|
|
442
|
+
if (browserTextCache.size > 512) {
|
|
443
|
+
const firstKey = browserTextCache.keys().next().value;
|
|
444
|
+
const old = browserTextCache.get(firstKey);
|
|
445
|
+
if (old && old.image && old.image.delete) old.image.delete();
|
|
446
|
+
browserTextCache.delete(firstKey);
|
|
447
|
+
}
|
|
448
|
+
return entry;
|
|
449
|
+
};
|
|
450
|
+
// Bounded rgb→ColorFilter cache for tinting the WHITE text raster at draw.
|
|
451
|
+
// `MakeBlend(color, SrcIn)` recolours the mask to `color × coverage`; alpha
|
|
452
|
+
// is NOT part of the filter (it rides the paint via setAlphaf), so the key is
|
|
453
|
+
// rgb only and one filter serves every opacity of a colour. Filters are wasm
|
|
454
|
+
// handles — LRU with a hard cap, `.delete()`ing the evicted handle (mirrors
|
|
455
|
+
// `svgPathCache`'s wasm-object hygiene). Cap is small: distinct text colours
|
|
456
|
+
// on screen are few, so this never thrashes the way the old RGBA raster key
|
|
457
|
+
// did. An evicted filter was already consumed by its draw call (immediate
|
|
458
|
+
// mode), and `cachedTintPaint` re-`setColorFilter`s before every draw, so a
|
|
459
|
+
// deleted handle is never dereferenced.
|
|
460
|
+
const TINT_FILTER_CACHE_CAP = 64;
|
|
461
|
+
const tintFilterCache = new Map();
|
|
462
|
+
const tintColorFilter = (r, g, b) => {
|
|
463
|
+
if (!(CK.ColorFilter && CK.ColorFilter.MakeBlend)) return null;
|
|
464
|
+
const key = r + '\n' + g + '\n' + b;
|
|
465
|
+
const hit = tintFilterCache.get(key);
|
|
466
|
+
if (hit) {
|
|
467
|
+
tintFilterCache.delete(key);
|
|
468
|
+
tintFilterCache.set(key, hit);
|
|
469
|
+
return hit;
|
|
470
|
+
}
|
|
471
|
+
const cf = CK.ColorFilter.MakeBlend(col(r, g, b, 1), CK.BlendMode.SrcIn);
|
|
472
|
+
if (!cf) return null;
|
|
473
|
+
tintFilterCache.set(key, cf);
|
|
474
|
+
if (tintFilterCache.size > TINT_FILTER_CACHE_CAP) {
|
|
475
|
+
const firstKey = tintFilterCache.keys().next().value;
|
|
476
|
+
const old = tintFilterCache.get(firstKey);
|
|
477
|
+
if (old && old.delete) old.delete();
|
|
478
|
+
tintFilterCache.delete(firstKey);
|
|
479
|
+
}
|
|
480
|
+
return cf;
|
|
481
|
+
};
|
|
482
|
+
// Dedicated long-lived paint that tints the white text raster. Kept SEPARATE
|
|
483
|
+
// from cachedFillPaint / cachedStrokePaint because drawBrowserText runs while
|
|
484
|
+
// drawText's / drawScriptRun's fill paint `p` is already live in an outer
|
|
485
|
+
// loop — sharing would corrupt that paint mid-run (the same reason
|
|
486
|
+
// allocFillPaint exists). antiAlias stays at the CK.Paint default (false) so
|
|
487
|
+
// image sampling matches the prior no-paint drawImage exactly.
|
|
488
|
+
const cachedTintPaint = new CK.Paint();
|
|
489
|
+
const drawBrowserText = (t, x, y, sz, weight, italic, r, g, b, a, emoji, family = '') => {
|
|
490
|
+
const entry = browserTextImage(t, sz, weight, italic, emoji, r, g, b, a, family);
|
|
491
|
+
if (!entry) return 0;
|
|
492
|
+
// Emoji rasters bake their own colour and draw UNTINTED (legacy path);
|
|
493
|
+
// text rasters are white masks tinted here. For text: tint to (r,g,b) via a
|
|
494
|
+
// cached SrcIn ColorFilter with opacity `a` riding the paint — SrcIn keeps
|
|
495
|
+
// alpha = coverage × a and rgb = colour, pixel-identical to the old
|
|
496
|
+
// baked-colour raster.
|
|
497
|
+
let paint = null;
|
|
498
|
+
if (!entry.emoji) {
|
|
499
|
+
paint = cachedTintPaint;
|
|
500
|
+
paint.setColorFilter(tintColorFilter(r, g, b) || null);
|
|
501
|
+
paint.setAlphaf(a < 0 ? 0 : a > 1 ? 1 : a);
|
|
502
|
+
}
|
|
503
|
+
const ss = entry.ss || 1;
|
|
504
|
+
if (ss !== 1) {
|
|
505
|
+
// The bitmap is `ss`x oversampled; place it in logical space at
|
|
506
|
+
// (x-2, y-baseline) then scale down by `ss` so its device footprint
|
|
507
|
+
// matches the intended CSS box at native resolution.
|
|
508
|
+
canvas.save();
|
|
509
|
+
canvas.translate(x - 2, y - entry.baseline);
|
|
510
|
+
canvas.scale(1 / ss, 1 / ss);
|
|
511
|
+
if (paint) canvas.drawImage(entry.image, 0, 0, paint);
|
|
512
|
+
else canvas.drawImage(entry.image, 0, 0);
|
|
513
|
+
canvas.restore();
|
|
514
|
+
} else if (paint) {
|
|
515
|
+
canvas.drawImage(entry.image, x - 2, y - entry.baseline, paint);
|
|
516
|
+
} else {
|
|
517
|
+
canvas.drawImage(entry.image, x - 2, y - entry.baseline);
|
|
518
|
+
}
|
|
519
|
+
return entry.width;
|
|
520
|
+
};
|
|
521
|
+
const shaderPaint = (shader) => { const p = new CK.Paint(); p.setAntiAlias(true); setPaintStyle(p, CK.PaintStyle.Fill); p.setShader(shader); return p; };
|
|
522
|
+
const firstStopColor = (stops, opacity) => stops.length >= 5
|
|
523
|
+
? col(
|
|
524
|
+
opCkClampUnit(stops[1]),
|
|
525
|
+
opCkClampUnit(stops[2]),
|
|
526
|
+
opCkClampUnit(stops[3]),
|
|
527
|
+
opCkClampUnit(stops[4]) * opCkClampUnit(opacity),
|
|
528
|
+
)
|
|
529
|
+
: col(0, 0, 0, 0);
|
|
530
|
+
const gradientStops = (stops, opacity) => {
|
|
531
|
+
const colors = [];
|
|
532
|
+
const offsets = [];
|
|
533
|
+
const alpha = opCkClampUnit(opacity);
|
|
534
|
+
for (let i = 0; i + 4 < stops.length; i += 5) {
|
|
535
|
+
offsets.push(opCkClampUnit(stops[i]));
|
|
536
|
+
colors.push(col(
|
|
537
|
+
opCkClampUnit(stops[i + 1]),
|
|
538
|
+
opCkClampUnit(stops[i + 2]),
|
|
539
|
+
opCkClampUnit(stops[i + 3]),
|
|
540
|
+
opCkClampUnit(stops[i + 4]) * alpha,
|
|
541
|
+
));
|
|
542
|
+
}
|
|
543
|
+
return { colors, offsets };
|
|
544
|
+
};
|
|
545
|
+
const linearGradientPoints = (x, y, w, h, angleDeg) => {
|
|
546
|
+
const rad = (angleDeg - 90) * Math.PI / 180;
|
|
547
|
+
const cx = x + w / 2;
|
|
548
|
+
const cy = y + h / 2;
|
|
549
|
+
const dx = Math.cos(rad) * w / 2;
|
|
550
|
+
const dy = Math.sin(rad) * h / 2;
|
|
551
|
+
return { start: [cx - dx, cy - dy], end: [cx + dx, cy + dy] };
|
|
552
|
+
};
|
|
553
|
+
const uniformRRect = (x, y, w, h, radius) => CK.RRectXY(
|
|
554
|
+
CK.LTRBRect(x, y, x + w, y + h),
|
|
555
|
+
Math.max(0, radius),
|
|
556
|
+
Math.max(0, radius),
|
|
557
|
+
);
|
|
558
|
+
const perCornerRRect = (x, y, w, h, topLeft, topRight, bottomRight, bottomLeft) => Float32Array.of(
|
|
559
|
+
x, y, x + w, y + h,
|
|
560
|
+
Math.max(0, topLeft), Math.max(0, topLeft),
|
|
561
|
+
Math.max(0, topRight), Math.max(0, topRight),
|
|
562
|
+
Math.max(0, bottomRight), Math.max(0, bottomRight),
|
|
563
|
+
Math.max(0, bottomLeft), Math.max(0, bottomLeft),
|
|
564
|
+
);
|
|
565
|
+
const drawGradientRRect = (rrect, shader, fallbackColor) => {
|
|
566
|
+
if (!shader) {
|
|
567
|
+
canvas.drawRRect(rrect, fillPaint(...fallbackColor));
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const paint = shaderPaint(shader);
|
|
571
|
+
try {
|
|
572
|
+
canvas.drawRRect(rrect, paint);
|
|
573
|
+
} finally {
|
|
574
|
+
paint.delete();
|
|
575
|
+
if (shader.delete) shader.delete();
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
const makeLinearGradient = (x, y, w, h, stops, angleDeg, opacity) => {
|
|
579
|
+
const gradient = gradientStops(stops, opacity);
|
|
580
|
+
if (!gradient.colors.length) return null;
|
|
581
|
+
const points = linearGradientPoints(x, y, w, h, angleDeg);
|
|
582
|
+
try {
|
|
583
|
+
// Flag 1 requests interpolation in premultiplied colour space, matching
|
|
584
|
+
// the native Skia backend and avoiding halos around transparent stops.
|
|
585
|
+
return CK.Shader.MakeLinearGradient(
|
|
586
|
+
points.start,
|
|
587
|
+
points.end,
|
|
588
|
+
gradient.colors,
|
|
589
|
+
gradient.offsets,
|
|
590
|
+
CK.TileMode.Clamp,
|
|
591
|
+
null,
|
|
592
|
+
1,
|
|
593
|
+
);
|
|
594
|
+
} catch (_error) {
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
const makeRadialGradient = (x, y, w, h, stops, cxFrac, cyFrac, radiusFrac, opacity) => {
|
|
599
|
+
const gradient = gradientStops(stops, opacity);
|
|
600
|
+
if (!gradient.colors.length) return null;
|
|
601
|
+
const center = [
|
|
602
|
+
x + w * opCkClampUnit(cxFrac),
|
|
603
|
+
y + h * opCkClampUnit(cyFrac),
|
|
604
|
+
];
|
|
605
|
+
const radius = Math.max(0.01, Math.max(w, h) * opCkClampUnit(radiusFrac));
|
|
606
|
+
try {
|
|
607
|
+
return CK.Shader.MakeRadialGradient(
|
|
608
|
+
center,
|
|
609
|
+
radius,
|
|
610
|
+
gradient.colors,
|
|
611
|
+
gradient.offsets,
|
|
612
|
+
CK.TileMode.Clamp,
|
|
613
|
+
null,
|
|
614
|
+
1,
|
|
615
|
+
);
|
|
616
|
+
} catch (_error) {
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
const drawMeshGradientRRect = (rrect, x, y, w, h, rows, cols, colors, opacity) => {
|
|
621
|
+
const alpha = opCkClampUnit(opacity);
|
|
622
|
+
const fallback = colors.length >= 4
|
|
623
|
+
? [
|
|
624
|
+
opCkClampUnit(colors[0]),
|
|
625
|
+
opCkClampUnit(colors[1]),
|
|
626
|
+
opCkClampUnit(colors[2]),
|
|
627
|
+
opCkClampUnit(colors[3]) * alpha,
|
|
628
|
+
]
|
|
629
|
+
: [0, 0, 0, 0];
|
|
630
|
+
const mesh = opCkBuildMeshGradientData(x, y, w, h, rows, cols, colors);
|
|
631
|
+
if (!mesh || typeof CK.MakeVertices !== 'function') {
|
|
632
|
+
canvas.drawRRect(rrect, fillPaint(...fallback));
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
let vertices = null;
|
|
637
|
+
try {
|
|
638
|
+
vertices = CK.MakeVertices(
|
|
639
|
+
CK.VertexMode.Triangles,
|
|
640
|
+
mesh.positions,
|
|
641
|
+
null,
|
|
642
|
+
mesh.rgba,
|
|
643
|
+
mesh.indices,
|
|
644
|
+
true,
|
|
645
|
+
);
|
|
646
|
+
} catch (_error) {
|
|
647
|
+
vertices = null;
|
|
648
|
+
}
|
|
649
|
+
if (!vertices) {
|
|
650
|
+
canvas.drawRRect(rrect, fillPaint(...fallback));
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const paint = allocFillPaint(1, 1, 1, alpha);
|
|
655
|
+
canvas.save();
|
|
656
|
+
try {
|
|
657
|
+
canvas.clipRRect(rrect, CK.ClipOp.Intersect, true);
|
|
658
|
+
canvas.drawVertices(vertices, CK.BlendMode.Modulate, paint);
|
|
659
|
+
} finally {
|
|
660
|
+
canvas.restore();
|
|
661
|
+
paint.delete();
|
|
662
|
+
vertices.delete();
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
const fontCovers = (entry, text) => {
|
|
666
|
+
if (!entry || !entry.tf || !text) return false;
|
|
667
|
+
const cacheKey = entry.key + '\n' + text;
|
|
668
|
+
if (coverageCache.has(cacheKey)) return coverageCache.get(cacheKey);
|
|
669
|
+
const f = new CK.Font(entry.tf, 16);
|
|
670
|
+
const ids = f.getGlyphIDs(text);
|
|
671
|
+
const ok = ids.length > 0 && ids.every((id) => id !== 0);
|
|
672
|
+
f.delete();
|
|
673
|
+
coverageCache.set(cacheKey, ok);
|
|
674
|
+
return ok;
|
|
675
|
+
};
|
|
676
|
+
const systemTypefaceFor = (t, emojiRun) => {
|
|
677
|
+
const preferred = emojiRun
|
|
678
|
+
? systemTypefaces.filter((entry) => entry.emoji)
|
|
679
|
+
: hasCjk(t)
|
|
680
|
+
? systemTypefaces.filter((entry) => entry.cjk)
|
|
681
|
+
: hasSystemFallbackText(t)
|
|
682
|
+
? systemTypefaces.filter((entry) => entry.textFallback)
|
|
683
|
+
: [];
|
|
684
|
+
for (const entry of preferred) {
|
|
685
|
+
if (fontCovers(entry, t)) return entry.tf;
|
|
686
|
+
}
|
|
687
|
+
if (!emojiRun && hasSystemFallbackText(t)) {
|
|
688
|
+
for (const entry of systemTypefaces) {
|
|
689
|
+
if (fontCovers(entry, t)) return entry.tf;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return null;
|
|
693
|
+
};
|
|
694
|
+
const tfFor = (t, emojiRun) => systemTypefaceFor(t, emojiRun) || CK.Typeface.GetDefault();
|
|
695
|
+
const runWidth = (f, s) => { const ids = f.getGlyphIDs(s); return f.getGlyphWidths(ids).reduce((a, v) => a + v, 0); };
|
|
696
|
+
// Normalize the primary family for imported-font registry mutations. Text
|
|
697
|
+
// lookup itself uses every authored candidate via `familyTypefaceEntry`.
|
|
698
|
+
const primaryFamilyKey = (family) => {
|
|
699
|
+
const first = opCkParseFontFamilyStack(family)[0] || '';
|
|
700
|
+
if (!first) return '';
|
|
701
|
+
const key = normalizedFamily(first);
|
|
702
|
+
return CSS_GENERIC_FONT_FAMILIES.has(key) ? '' : key;
|
|
703
|
+
};
|
|
704
|
+
// Resolve an explicit imported/system face in authored stack order. Generic
|
|
705
|
+
// candidates intentionally return null and continue through browser text.
|
|
706
|
+
const familyTypefaceEntry = (family) => {
|
|
707
|
+
return opCkResolveRegisteredTypeface(family, importedTypefaces, systemTypefacesByFamily);
|
|
708
|
+
};
|
|
709
|
+
// Shared "typeface + font for (family, sz)" so the family-aware draw and
|
|
710
|
+
// measure paths build the SAME CK.Font and agree to sub-pixel. Cached per
|
|
711
|
+
// `(registry + family key, size)` — this used to build + discard a
|
|
712
|
+
// fresh CK.Font on every selected segment of every draw/measure call.
|
|
713
|
+
// Iterations that use this font always fully consume it (draw or measure)
|
|
714
|
+
// before the next one is fetched, so a single shared instance per key is
|
|
715
|
+
// safe to reuse across loop iterations and across calls. Skew (italic) is
|
|
716
|
+
// reset unconditionally on every fetch since the same cached Font may be
|
|
717
|
+
// reused for an italic run and then a later upright run. Callers must NOT
|
|
718
|
+
// `.delete()` the returned Font; eviction (mirroring `svgPathCache`) frees
|
|
719
|
+
// every cached wasm Font before wiping the map. Invalidated wholesale by
|
|
720
|
+
// `clearRegisteredFontCaches` whenever either font registry changes.
|
|
721
|
+
const registeredFamilyFont = (key, tf, sz, italic) => {
|
|
722
|
+
const cacheKey = key + '\n' + sz;
|
|
723
|
+
let f = registeredFamilyFontCache.get(cacheKey);
|
|
724
|
+
if (!f) {
|
|
725
|
+
f = new CK.Font(tf, sz);
|
|
726
|
+
if (registeredFamilyFontCache.size >= REGISTERED_FONT_CACHE_CAP) {
|
|
727
|
+
for (const v of registeredFamilyFontCache.values()) v.delete();
|
|
728
|
+
registeredFamilyFontCache.clear();
|
|
729
|
+
}
|
|
730
|
+
registeredFamilyFontCache.set(cacheKey, f);
|
|
731
|
+
}
|
|
732
|
+
f.setSkewX(italic ? -0.25 : 0);
|
|
733
|
+
return f;
|
|
734
|
+
};
|
|
735
|
+
// Split a run into maximal {text, registered} segments by whether the named
|
|
736
|
+
// typeface has a glyph for each char (glyph id 0 = .notdef = uncovered). This
|
|
737
|
+
// is per-CHARACTER, so a mixed run keeps the selected face for the chars it
|
|
738
|
+
// covers and falls back (system/CJK/emoji) only for the rest — matching how
|
|
739
|
+
// native resolves a named family per character, instead of dropping the
|
|
740
|
+
// imported family for the whole run. `getGlyphIDs` returns one id per
|
|
741
|
+
// codepoint, so it aligns with the codepoint iteration; if the counts don't
|
|
742
|
+
// line up we conservatively treat the whole run as uncovered.
|
|
743
|
+
const registeredCoverageSegments = (key, tf, sz, t) => {
|
|
744
|
+
const cacheKey = key + '\n' + sz + '\n' + t;
|
|
745
|
+
const cached = registeredCoverageCache.get(cacheKey);
|
|
746
|
+
if (cached) return cached;
|
|
747
|
+
const cps = Array.from(t);
|
|
748
|
+
let segs;
|
|
749
|
+
if (cps.length === 0) {
|
|
750
|
+
segs = [];
|
|
751
|
+
} else {
|
|
752
|
+
const f = new CK.Font(tf, sz);
|
|
753
|
+
let ids = null;
|
|
754
|
+
try {
|
|
755
|
+
ids = f.getGlyphIDs(t);
|
|
756
|
+
} catch (e) {
|
|
757
|
+
ids = null;
|
|
758
|
+
}
|
|
759
|
+
f.delete();
|
|
760
|
+
if (!ids || ids.length !== cps.length) {
|
|
761
|
+
segs = [{ text: t, registered: false }];
|
|
762
|
+
} else {
|
|
763
|
+
const out = [];
|
|
764
|
+
let cur = '';
|
|
765
|
+
let curRegistered = null;
|
|
766
|
+
for (let i = 0; i < cps.length; i++) {
|
|
767
|
+
const registered = ids[i] !== 0;
|
|
768
|
+
if (curRegistered === null) {
|
|
769
|
+
curRegistered = registered;
|
|
770
|
+
cur = cps[i];
|
|
771
|
+
} else if (registered === curRegistered) {
|
|
772
|
+
cur += cps[i];
|
|
773
|
+
} else {
|
|
774
|
+
out.push({ text: cur, registered: curRegistered });
|
|
775
|
+
cur = cps[i];
|
|
776
|
+
curRegistered = registered;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
if (cur) out.push({ text: cur, registered: curRegistered });
|
|
780
|
+
segs = out;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
registeredCoverageCache.set(cacheKey, segs);
|
|
784
|
+
if (registeredCoverageCache.size > REGISTERED_COVERAGE_CACHE_CAP) {
|
|
785
|
+
const firstKey = registeredCoverageCache.keys().next().value;
|
|
786
|
+
registeredCoverageCache.delete(firstKey);
|
|
787
|
+
}
|
|
788
|
+
return segs;
|
|
789
|
+
};
|
|
790
|
+
// Draw a run via the script-segmented fallback (system / CJK / emoji /
|
|
791
|
+
// browser-canvas), returning the advance consumed. Shared by the
|
|
792
|
+
// family-blind path and the uncovered segments of a family-aware run, so the
|
|
793
|
+
// two stay identical. Mirrors `measureTextStyled` advance-for-advance.
|
|
794
|
+
const drawScriptRun = (t, x, y, sz, weight, italic, r, g, b, a) => {
|
|
795
|
+
const segs = segments(t);
|
|
796
|
+
if (segs.length === 0) return 0;
|
|
797
|
+
if (allSegmentsUseBrowserTextFallback(segs)) {
|
|
798
|
+
let cx = x;
|
|
799
|
+
for (const seg of segs) cx += drawBrowserText(seg.text, cx, y, sz, weight, italic, r, g, b, a, seg.emoji);
|
|
800
|
+
return cx - x;
|
|
801
|
+
}
|
|
802
|
+
const p = allocFillPaint(r, g, b, a);
|
|
803
|
+
if (weight >= 600 && isPaintStyle(CK.PaintStyle.StrokeAndFill)) {
|
|
804
|
+
setPaintStyle(p, CK.PaintStyle.StrokeAndFill);
|
|
805
|
+
p.setStrokeWidth(sz * 0.06);
|
|
806
|
+
}
|
|
807
|
+
let cx = x;
|
|
808
|
+
for (const seg of segs) {
|
|
809
|
+
if (shouldUseBrowserTextFallback(seg.text, seg.emoji)) {
|
|
810
|
+
cx += drawBrowserText(seg.text, cx, y, sz, weight, italic, r, g, b, a, seg.emoji);
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
const f = new CK.Font(tfFor(seg.text, seg.emoji), sz);
|
|
814
|
+
if (italic && !seg.emoji) f.setSkewX(-0.25);
|
|
815
|
+
canvas.drawText(seg.text, cx, y, p, f);
|
|
816
|
+
cx += runWidth(f, seg.text);
|
|
817
|
+
f.delete();
|
|
818
|
+
}
|
|
819
|
+
p.delete();
|
|
820
|
+
return cx - x;
|
|
821
|
+
};
|
|
822
|
+
const pathIsFinite = (bounds) => bounds && bounds.length >= 4 && bounds.every((v) => Number.isFinite(v));
|
|
823
|
+
const fitPathToRect = (path, x, y, w, h) => {
|
|
824
|
+
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
|
|
825
|
+
path.transform(CK.Matrix.translated(x, y));
|
|
826
|
+
return path;
|
|
827
|
+
}
|
|
828
|
+
// Match native Skia's `compute_tight_bounds`: control points may sit far
|
|
829
|
+
// outside the actual Bezier curve, so loose bounds can visibly shrink or
|
|
830
|
+
// offset a fitted path. Older CanvasKit builds lack the tight API; retain
|
|
831
|
+
// `getBounds` as a compatibility fallback.
|
|
832
|
+
let bounds = null;
|
|
833
|
+
if (typeof path.computeTightBounds === 'function') {
|
|
834
|
+
try { bounds = path.computeTightBounds(); } catch (_error) { bounds = null; }
|
|
835
|
+
}
|
|
836
|
+
if (!pathIsFinite(bounds)) {
|
|
837
|
+
try { bounds = path.getBounds(); } catch (_error) { bounds = null; }
|
|
838
|
+
}
|
|
839
|
+
if (!pathIsFinite(bounds)) {
|
|
840
|
+
path.transform(CK.Matrix.translated(x, y));
|
|
841
|
+
return path;
|
|
842
|
+
}
|
|
843
|
+
const nativeW = bounds[2] - bounds[0];
|
|
844
|
+
const nativeH = bounds[3] - bounds[1];
|
|
845
|
+
const sx = Math.abs(nativeW) > 0.01 ? w / nativeW : 1;
|
|
846
|
+
const sy = Math.abs(nativeH) > 0.01 ? h / nativeH : 1;
|
|
847
|
+
const tx = x - bounds[0] * sx;
|
|
848
|
+
const ty = y - bounds[1] * sy;
|
|
849
|
+
path.transform(CK.Matrix.multiply(CK.Matrix.translated(tx, ty), CK.Matrix.scaled(sx, sy)));
|
|
850
|
+
return path;
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
// Parsed-SVG-path cache. `CK.Path.MakeFromSVGString` is the dominant per-icon
|
|
854
|
+
// cost, and every chrome icon / lucide glyph / brand logo / vector node
|
|
855
|
+
// re-parsed its `d` string on EVERY frame (mirrors what the native backend
|
|
856
|
+
// avoids via `svg_path_cache`). Cache the parsed, untransformed path keyed on
|
|
857
|
+
// `d`; callers draw a `.copy()` they transform + delete, leaving the cached
|
|
858
|
+
// original pristine. Bounded — on overflow drop all (a full refill is one
|
|
859
|
+
// frame) and delete the wasm-heap paths so they don't leak.
|
|
860
|
+
const SVG_PATH_CACHE_CAP = 1024;
|
|
861
|
+
const svgPathCache = new Map();
|
|
862
|
+
const cachedSvgPath = (d) => {
|
|
863
|
+
let base = svgPathCache.get(d);
|
|
864
|
+
if (!base) {
|
|
865
|
+
base = CK.Path.MakeFromSVGString(d);
|
|
866
|
+
if (!base) return null;
|
|
867
|
+
if (svgPathCache.size >= SVG_PATH_CACHE_CAP) {
|
|
868
|
+
for (const v of svgPathCache.values()) v.delete();
|
|
869
|
+
svgPathCache.clear();
|
|
870
|
+
}
|
|
871
|
+
svgPathCache.set(d, base);
|
|
872
|
+
}
|
|
873
|
+
return base.copy();
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
const imageCaches = createWebImageCaches(CK);
|
|
877
|
+
|
|
878
|
+
// Figma maps node-normalized coordinates to normalized image UV. Image
|
|
879
|
+
// shaders consume the inverse, mapping image pixels into the destination
|
|
880
|
+
// rect: node_rect * inverse(figma) * inverse(image_dimensions).
|
|
881
|
+
const figmaImageLocalMatrix = (x, y, w, h, imageW, imageH, transform) => {
|
|
882
|
+
if (transform.length !== 6 || !(w > 0) || !(h > 0) || !(imageW > 0) || !(imageH > 0)) return null;
|
|
883
|
+
const [a, b, tx, c, d, ty] = transform;
|
|
884
|
+
const det = a * d - b * c;
|
|
885
|
+
if (!Number.isFinite(det) || Math.abs(det) <= Number.EPSILON) return null;
|
|
886
|
+
const invDet = 1 / det;
|
|
887
|
+
const ia = d * invDet;
|
|
888
|
+
const ib = -b * invDet;
|
|
889
|
+
const ic = -c * invDet;
|
|
890
|
+
const id = a * invDet;
|
|
891
|
+
const itx = (b * ty - d * tx) * invDet;
|
|
892
|
+
const ity = (c * tx - a * ty) * invDet;
|
|
893
|
+
return Float32Array.of(
|
|
894
|
+
w * ia / imageW, w * ib / imageH, x + w * itx,
|
|
895
|
+
h * ic / imageW, h * id / imageH, y + h * ity,
|
|
896
|
+
0, 0, 1,
|
|
897
|
+
);
|
|
898
|
+
};
|
|
899
|
+
|
|
900
|
+
const imageAdjustmentMatrix = (values) => {
|
|
901
|
+
if (values.length !== 7 || values.every((v) => v === 0)) return null;
|
|
902
|
+
const exp = values[0] / 100;
|
|
903
|
+
const con = values[1] / 100;
|
|
904
|
+
const sat = values[2] / 100;
|
|
905
|
+
const temp = values[3] / 100;
|
|
906
|
+
const tint = values[4] / 100;
|
|
907
|
+
const hi = values[5] / 100;
|
|
908
|
+
const sh = values[6] / 100;
|
|
909
|
+
const e = 1 + exp * 1.5;
|
|
910
|
+
const contrast = 1 + con;
|
|
911
|
+
const contrastOffset = 0.5 * (1 - contrast);
|
|
912
|
+
const saturation = 1 + sat;
|
|
913
|
+
const [lr, lg, lb] = [0.2126, 0.7152, 0.0722];
|
|
914
|
+
const [sr, sg, sb] = [(1 - saturation) * lr, (1 - saturation) * lg, (1 - saturation) * lb];
|
|
915
|
+
const f = contrast * e;
|
|
916
|
+
const common = (hi + sh * 0.5) * 0.1;
|
|
917
|
+
return Float32Array.of(
|
|
918
|
+
f * (sr + saturation), f * sg, f * sb, 0, contrastOffset + temp * 0.15 + common,
|
|
919
|
+
f * sr, f * (sg + saturation), f * sb, 0, contrastOffset + tint * 0.15 + common,
|
|
920
|
+
f * sr, f * sg, f * (sb + saturation), 0, contrastOffset - temp * 0.15 + common,
|
|
921
|
+
0, 0, 0, 1, 0,
|
|
922
|
+
);
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
const blendModeForCode = (blendMode) => [
|
|
926
|
+
CK.BlendMode.SrcOver,
|
|
927
|
+
CK.BlendMode.Darken,
|
|
928
|
+
CK.BlendMode.Multiply,
|
|
929
|
+
CK.BlendMode.Screen,
|
|
930
|
+
CK.BlendMode.Overlay,
|
|
931
|
+
CK.BlendMode.Lighten,
|
|
932
|
+
CK.BlendMode.Difference,
|
|
933
|
+
CK.BlendMode.Hue,
|
|
934
|
+
CK.BlendMode.Saturation,
|
|
935
|
+
CK.BlendMode.Color,
|
|
936
|
+
CK.BlendMode.Luminosity,
|
|
937
|
+
CK.BlendMode.SoftLight,
|
|
938
|
+
CK.BlendMode.ColorDodge,
|
|
939
|
+
CK.BlendMode.ColorBurn,
|
|
940
|
+
CK.BlendMode.HardLight,
|
|
941
|
+
CK.BlendMode.Exclusion,
|
|
942
|
+
][blendMode] || CK.BlendMode.SrcOver;
|
|
943
|
+
|
|
944
|
+
const drawImageRectLinear = (image, src, dst, paint) => {
|
|
945
|
+
if (canvas.drawImageRectOptions) {
|
|
946
|
+
canvas.drawImageRectOptions(image, src, dst, CK.FilterMode.Linear, CK.MipmapMode.None, paint);
|
|
947
|
+
} else {
|
|
948
|
+
canvas.drawImageRect(image, src, dst, paint, false);
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
return {
|
|
953
|
+
beginFrame() {
|
|
954
|
+
// Discard any leaked save / clip / matrix state from a prior (possibly
|
|
955
|
+
// unbalanced) paint pass, then open a clean frame baseline. This mirrors
|
|
956
|
+
// the native backend's per-frame `reset_matrix()`: without it a single
|
|
957
|
+
// unbalanced save/clip in the widget composition would accumulate across
|
|
958
|
+
// repaints and corrupt z-order / clipping (e.g. a frame fill bleeding
|
|
959
|
+
// over later layers).
|
|
960
|
+
while (canvas.getSaveCount() > 1) canvas.restore();
|
|
961
|
+
canvas.save();
|
|
962
|
+
},
|
|
963
|
+
endFrame() {
|
|
964
|
+
while (canvas.getSaveCount() > 1) canvas.restore();
|
|
965
|
+
surface.flush();
|
|
966
|
+
},
|
|
967
|
+
clear(r, g, b, a) { canvas.clear(col(r, g, b, a)); },
|
|
968
|
+
|
|
969
|
+
fillRect(x, y, w, h, r, g, b, a) { const p = fillPaint(r, g, b, a); canvas.drawRect(CK.LTRBRect(x, y, x + w, y + h), p); },
|
|
970
|
+
strokeRect(x, y, w, h, r, g, b, a, sw) { const p = strokePaint(r, g, b, a, sw); canvas.drawRect(CK.LTRBRect(x, y, x + w, y + h), p); },
|
|
971
|
+
fillRoundRect(x, y, w, h, rad, r, g, b, a) { const p = fillPaint(r, g, b, a); canvas.drawRRect(CK.RRectXY(CK.LTRBRect(x, y, x + w, y + h), rad, rad), p); },
|
|
972
|
+
fillRoundRectPerCorner(x, y, w, h, tl, tr, br, bl, r, g, b, a) {
|
|
973
|
+
const rr = Float32Array.of(x, y, x + w, y + h, tl, tl, tr, tr, br, br, bl, bl);
|
|
974
|
+
const p = fillPaint(r, g, b, a); canvas.drawRRect(rr, p);
|
|
975
|
+
},
|
|
976
|
+
fillRoundRectLinearGradient(x, y, w, h, radius, stops, angleDeg, opacity) {
|
|
977
|
+
if (stops.length < 5) return;
|
|
978
|
+
drawGradientRRect(
|
|
979
|
+
uniformRRect(x, y, w, h, radius),
|
|
980
|
+
makeLinearGradient(x, y, w, h, stops, angleDeg, opacity),
|
|
981
|
+
firstStopColor(stops, opacity),
|
|
982
|
+
);
|
|
983
|
+
},
|
|
984
|
+
fillRoundRectLinearGradientPerCorner(x, y, w, h, tl, tr, br, bl, stops, angleDeg, opacity) {
|
|
985
|
+
if (stops.length < 5) return;
|
|
986
|
+
drawGradientRRect(
|
|
987
|
+
perCornerRRect(x, y, w, h, tl, tr, br, bl),
|
|
988
|
+
makeLinearGradient(x, y, w, h, stops, angleDeg, opacity),
|
|
989
|
+
firstStopColor(stops, opacity),
|
|
990
|
+
);
|
|
991
|
+
},
|
|
992
|
+
fillRoundRectRadialGradient(x, y, w, h, radius, stops, cxFrac, cyFrac, radiusFrac, opacity) {
|
|
993
|
+
if (stops.length < 5) return;
|
|
994
|
+
drawGradientRRect(
|
|
995
|
+
uniformRRect(x, y, w, h, radius),
|
|
996
|
+
makeRadialGradient(x, y, w, h, stops, cxFrac, cyFrac, radiusFrac, opacity),
|
|
997
|
+
firstStopColor(stops, opacity),
|
|
998
|
+
);
|
|
999
|
+
},
|
|
1000
|
+
fillRoundRectRadialGradientPerCorner(x, y, w, h, tl, tr, br, bl, stops, cxFrac, cyFrac, radiusFrac, opacity) {
|
|
1001
|
+
if (stops.length < 5) return;
|
|
1002
|
+
drawGradientRRect(
|
|
1003
|
+
perCornerRRect(x, y, w, h, tl, tr, br, bl),
|
|
1004
|
+
makeRadialGradient(x, y, w, h, stops, cxFrac, cyFrac, radiusFrac, opacity),
|
|
1005
|
+
firstStopColor(stops, opacity),
|
|
1006
|
+
);
|
|
1007
|
+
},
|
|
1008
|
+
fillRoundRectMeshGradient(x, y, w, h, radius, rows, cols, colors, opacity) {
|
|
1009
|
+
if (colors.length < 4) return;
|
|
1010
|
+
drawMeshGradientRRect(
|
|
1011
|
+
uniformRRect(x, y, w, h, radius),
|
|
1012
|
+
x, y, w, h, rows, cols, colors, opacity,
|
|
1013
|
+
);
|
|
1014
|
+
},
|
|
1015
|
+
fillRoundRectMeshGradientPerCorner(x, y, w, h, tl, tr, br, bl, rows, cols, colors, opacity) {
|
|
1016
|
+
if (colors.length < 4) return;
|
|
1017
|
+
drawMeshGradientRRect(
|
|
1018
|
+
perCornerRRect(x, y, w, h, tl, tr, br, bl),
|
|
1019
|
+
x, y, w, h, rows, cols, colors, opacity,
|
|
1020
|
+
);
|
|
1021
|
+
},
|
|
1022
|
+
strokeRoundRect(x, y, w, h, rad, r, g, b, a, sw) { const p = strokePaint(r, g, b, a, sw); canvas.drawRRect(CK.RRectXY(CK.LTRBRect(x, y, x + w, y + h), rad, rad), p); },
|
|
1023
|
+
strokeRoundRectPerCorner(x, y, w, h, tl, tr, br, bl, r, g, b, a, sw) {
|
|
1024
|
+
const rr = Float32Array.of(x, y, x + w, y + h, tl, tl, tr, tr, br, br, bl, bl);
|
|
1025
|
+
const p = strokePaint(r, g, b, a, sw); canvas.drawRRect(rr, p);
|
|
1026
|
+
},
|
|
1027
|
+
fillOval(x, y, w, h, r, g, b, a) { const p = fillPaint(r, g, b, a); canvas.drawOval(CK.LTRBRect(x, y, x + w, y + h), p); },
|
|
1028
|
+
strokeOval(x, y, w, h, r, g, b, a, sw) { const p = strokePaint(r, g, b, a, sw); canvas.drawOval(CK.LTRBRect(x, y, x + w, y + h), p); },
|
|
1029
|
+
strokeLine(x1, y1, x2, y2, r, g, b, a, sw) { const p = strokePaint(r, g, b, a, sw); canvas.drawLine(x1, y1, x2, y2, p); },
|
|
1030
|
+
|
|
1031
|
+
fillPolygon(pts, r, g, b, a) {
|
|
1032
|
+
const path = new CK.Path(); path.moveTo(pts[0], pts[1]);
|
|
1033
|
+
for (let i = 2; i < pts.length; i += 2) path.lineTo(pts[i], pts[i + 1]);
|
|
1034
|
+
path.close();
|
|
1035
|
+
const p = fillPaint(r, g, b, a); canvas.drawPath(path, p); path.delete();
|
|
1036
|
+
},
|
|
1037
|
+
// SVG path d-string scaled by `size/viewbox` and translated to (tx,ty).
|
|
1038
|
+
strokeSvgPath(d, tx, ty, scale, r, g, b, a, sw) {
|
|
1039
|
+
const path = cachedSvgPath(d); if (!path) return;
|
|
1040
|
+
const m = CK.Matrix.multiply(CK.Matrix.translated(tx, ty), CK.Matrix.scaled(scale, scale));
|
|
1041
|
+
path.transform(m);
|
|
1042
|
+
const p = strokePaint(r, g, b, a, sw); canvas.drawPath(path, p); path.delete();
|
|
1043
|
+
},
|
|
1044
|
+
fillSvgPath(d, tx, ty, scale, evenOdd, r, g, b, a) {
|
|
1045
|
+
const path = cachedSvgPath(d); if (!path) return;
|
|
1046
|
+
if (evenOdd) path.setFillType(CK.FillType.EvenOdd);
|
|
1047
|
+
const m = CK.Matrix.multiply(CK.Matrix.translated(tx, ty), CK.Matrix.scaled(scale, scale));
|
|
1048
|
+
path.transform(m);
|
|
1049
|
+
const p = fillPaint(r, g, b, a); canvas.drawPath(path, p); path.delete();
|
|
1050
|
+
},
|
|
1051
|
+
fillSvgPathInRect(d, x, y, w, h, evenOdd, r, g, b, a) {
|
|
1052
|
+
const path = cachedSvgPath(d); if (!path) return;
|
|
1053
|
+
if (evenOdd) path.setFillType(CK.FillType.EvenOdd);
|
|
1054
|
+
fitPathToRect(path, x, y, w, h);
|
|
1055
|
+
const p = fillPaint(r, g, b, a); canvas.drawPath(path, p); path.delete();
|
|
1056
|
+
},
|
|
1057
|
+
strokeSvgPathInRect(d, x, y, w, h, r, g, b, a, sw) {
|
|
1058
|
+
const path = cachedSvgPath(d); if (!path) return;
|
|
1059
|
+
fitPathToRect(path, x, y, w, h);
|
|
1060
|
+
const p = strokePaint(r, g, b, a, sw); canvas.drawPath(path, p); path.delete();
|
|
1061
|
+
},
|
|
1062
|
+
fillSvgPathInRectLinearGradient(d, x, y, w, h, evenOdd, stops, angleDeg, opacity) {
|
|
1063
|
+
const path = cachedSvgPath(d); if (!path) return;
|
|
1064
|
+
if (evenOdd) path.setFillType(CK.FillType.EvenOdd);
|
|
1065
|
+
fitPathToRect(path, x, y, w, h);
|
|
1066
|
+
if (stops.length < 5) { path.delete(); return; }
|
|
1067
|
+
const shader = makeLinearGradient(x, y, w, h, stops, angleDeg, opacity);
|
|
1068
|
+
if (shader) {
|
|
1069
|
+
// shaderPaint always allocates fresh (one-off effect, excluded from
|
|
1070
|
+
// the shared cache) — this instance owns its own delete.
|
|
1071
|
+
const p = shaderPaint(shader);
|
|
1072
|
+
canvas.drawPath(path, p);
|
|
1073
|
+
p.delete();
|
|
1074
|
+
if (shader.delete) shader.delete();
|
|
1075
|
+
} else {
|
|
1076
|
+
const p = fillPaint(...firstStopColor(stops, opacity));
|
|
1077
|
+
canvas.drawPath(path, p);
|
|
1078
|
+
}
|
|
1079
|
+
path.delete();
|
|
1080
|
+
},
|
|
1081
|
+
fillSvgPathInRectRadialGradient(d, x, y, w, h, evenOdd, stops, cxFrac, cyFrac, radiusFrac, opacity) {
|
|
1082
|
+
const path = cachedSvgPath(d); if (!path) return;
|
|
1083
|
+
if (evenOdd) path.setFillType(CK.FillType.EvenOdd);
|
|
1084
|
+
fitPathToRect(path, x, y, w, h);
|
|
1085
|
+
if (stops.length < 5) { path.delete(); return; }
|
|
1086
|
+
const shader = makeRadialGradient(x, y, w, h, stops, cxFrac, cyFrac, radiusFrac, opacity);
|
|
1087
|
+
if (shader) {
|
|
1088
|
+
// shaderPaint always allocates fresh (one-off effect, excluded from
|
|
1089
|
+
// the shared cache) — this instance owns its own delete.
|
|
1090
|
+
const p = shaderPaint(shader);
|
|
1091
|
+
canvas.drawPath(path, p);
|
|
1092
|
+
p.delete();
|
|
1093
|
+
if (shader.delete) shader.delete();
|
|
1094
|
+
} else {
|
|
1095
|
+
const p = fillPaint(...firstStopColor(stops, opacity));
|
|
1096
|
+
canvas.drawPath(path, p);
|
|
1097
|
+
}
|
|
1098
|
+
path.delete();
|
|
1099
|
+
},
|
|
1100
|
+
fillInnerShadowSvgPath(d, x, y, w, h, evenOdd, offsetX, offsetY, blur, r, g, b, a) {
|
|
1101
|
+
const path = cachedSvgPath(d); if (!path) return;
|
|
1102
|
+
if (evenOdd) path.setFillType(CK.FillType.EvenOdd);
|
|
1103
|
+
fitPathToRect(path, x, y, w, h);
|
|
1104
|
+
const offsetPath = cachedSvgPath(d);
|
|
1105
|
+
if (!offsetPath) { path.delete(); return; }
|
|
1106
|
+
if (evenOdd) offsetPath.setFillType(CK.FillType.EvenOdd);
|
|
1107
|
+
fitPathToRect(offsetPath, x, y, w, h);
|
|
1108
|
+
offsetPath.transform(CK.Matrix.translated(offsetX, offsetY));
|
|
1109
|
+
|
|
1110
|
+
canvas.save();
|
|
1111
|
+
canvas.clipPath(path, CK.ClipOp.Intersect, true);
|
|
1112
|
+
canvas.saveLayer(null, CK.LTRBRect(x, y, x + w, y + h));
|
|
1113
|
+
|
|
1114
|
+
const fill = fillPaint(r, g, b, a);
|
|
1115
|
+
canvas.drawPath(path, fill);
|
|
1116
|
+
|
|
1117
|
+
// Dedicated (allocated + deleted) paint: `cut` carries a one-off blend
|
|
1118
|
+
// mode + blur mask filter, so it is excluded from the shared fill-
|
|
1119
|
+
// paint cache by design — reusing the cache here would leak DstOut /
|
|
1120
|
+
// the blur mask into every later `fillPaint()` caller.
|
|
1121
|
+
const cut = allocFillPaint(0, 0, 0, 1);
|
|
1122
|
+
cut.setBlendMode(CK.BlendMode.DstOut);
|
|
1123
|
+
let mask = null;
|
|
1124
|
+
const sigma = blur * 0.5;
|
|
1125
|
+
if (sigma > 0 && CK.MaskFilter && CK.MaskFilter.MakeBlur) {
|
|
1126
|
+
mask = CK.MaskFilter.MakeBlur(CK.BlurStyle.Normal, sigma, false);
|
|
1127
|
+
if (mask) cut.setMaskFilter(mask);
|
|
1128
|
+
}
|
|
1129
|
+
canvas.drawPath(offsetPath, cut);
|
|
1130
|
+
|
|
1131
|
+
cut.delete(); if (mask && mask.delete) mask.delete();
|
|
1132
|
+
canvas.restore();
|
|
1133
|
+
canvas.restore();
|
|
1134
|
+
offsetPath.delete(); path.delete();
|
|
1135
|
+
},
|
|
1136
|
+
|
|
1137
|
+
imageDecoded(imageIdLo, imageIdHi, maxEdgePx) {
|
|
1138
|
+
return imageCaches.hasFullImage(imageIdLo, imageIdHi, maxEdgePx);
|
|
1139
|
+
},
|
|
1140
|
+
|
|
1141
|
+
decodeImage(imageIdLo, imageIdHi, encoded, maxEdgePx) {
|
|
1142
|
+
return imageCaches.installFullImage(imageIdLo, imageIdHi, encoded, maxEdgePx);
|
|
1143
|
+
},
|
|
1144
|
+
|
|
1145
|
+
drawImageThumb(imageIdLo, imageIdHi, x, y, w, h, jpeg) {
|
|
1146
|
+
imageCaches.drawThumbnailCover(canvas, imageIdLo, imageIdHi, jpeg, x, y, w, h);
|
|
1147
|
+
},
|
|
1148
|
+
|
|
1149
|
+
drawImageWithOptions(imageIdLo, imageIdHi, x, y, w, h, mode, transform, adjustments, opacity, cornerRadius, blendMode, originalWidth, originalHeight, tileScale) {
|
|
1150
|
+
const image = imageCaches.fullImage(imageIdLo, imageIdHi);
|
|
1151
|
+
if (!image || !(w > 0) || !(h > 0)) return;
|
|
1152
|
+
const imageW = image.width();
|
|
1153
|
+
const imageH = image.height();
|
|
1154
|
+
if (!(imageW > 0) || !(imageH > 0)) return;
|
|
1155
|
+
const dst = CK.LTRBRect(x, y, x + w, y + h);
|
|
1156
|
+
const src = CK.LTRBRect(0, 0, imageW, imageH);
|
|
1157
|
+
const paint = new CK.Paint();
|
|
1158
|
+
paint.setAntiAlias(true);
|
|
1159
|
+
paint.setAlphaf(Math.max(0, Math.min(1, opacity)));
|
|
1160
|
+
paint.setBlendMode(blendModeForCode(blendMode));
|
|
1161
|
+
const matrix = imageAdjustmentMatrix(adjustments);
|
|
1162
|
+
const colorFilter = matrix && CK.ColorFilter && CK.ColorFilter.MakeMatrix
|
|
1163
|
+
? CK.ColorFilter.MakeMatrix(matrix)
|
|
1164
|
+
: null;
|
|
1165
|
+
if (colorFilter) paint.setColorFilter(colorFilter);
|
|
1166
|
+
|
|
1167
|
+
canvas.save();
|
|
1168
|
+
if (cornerRadius > 0.5) {
|
|
1169
|
+
canvas.clipRRect(CK.RRectXY(dst, cornerRadius, cornerRadius), CK.ClipOp.Intersect, true);
|
|
1170
|
+
}
|
|
1171
|
+
let shader = null;
|
|
1172
|
+
const local = mode === 3 ? null : figmaImageLocalMatrix(x, y, w, h, imageW, imageH, transform);
|
|
1173
|
+
if (local) {
|
|
1174
|
+
canvas.clipRect(dst, CK.ClipOp.Intersect, true);
|
|
1175
|
+
const tileMode = typeof CK.TileMode.Decal !== 'undefined' ? CK.TileMode.Decal : CK.TileMode.Clamp;
|
|
1176
|
+
shader = image.makeShaderOptions(tileMode, tileMode, CK.FilterMode.Linear, CK.MipmapMode.None, local);
|
|
1177
|
+
if (shader) {
|
|
1178
|
+
paint.setShader(shader);
|
|
1179
|
+
canvas.drawRect(dst, paint);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
if (!shader) {
|
|
1183
|
+
if (mode === 1) {
|
|
1184
|
+
const scale = Math.min(w / imageW, h / imageH);
|
|
1185
|
+
const dw = imageW * scale;
|
|
1186
|
+
const dh = imageH * scale;
|
|
1187
|
+
drawImageRectLinear(image, src, CK.LTRBRect(x + (w - dw) / 2, y + (h - dh) / 2, x + (w + dw) / 2, y + (h + dh) / 2), paint);
|
|
1188
|
+
} else if (mode === 3) {
|
|
1189
|
+
canvas.clipRect(dst, CK.ClipOp.Intersect, true);
|
|
1190
|
+
const sourceW = Number.isFinite(originalWidth) && originalWidth > 0 ? originalWidth : imageW;
|
|
1191
|
+
const sourceH = Number.isFinite(originalHeight) && originalHeight > 0 ? originalHeight : imageH;
|
|
1192
|
+
const authoredTileScale = Number.isFinite(tileScale) && tileScale > 0 ? tileScale : 1.0;
|
|
1193
|
+
const maxRepeatsPerAxis = 128;
|
|
1194
|
+
const safeTileScale = Math.max(
|
|
1195
|
+
authoredTileScale,
|
|
1196
|
+
1 / sourceW,
|
|
1197
|
+
1 / sourceH,
|
|
1198
|
+
Math.abs(w) / (sourceW * maxRepeatsPerAxis),
|
|
1199
|
+
Math.abs(h) / (sourceH * maxRepeatsPerAxis),
|
|
1200
|
+
);
|
|
1201
|
+
const tileW = sourceW * safeTileScale;
|
|
1202
|
+
const tileH = sourceH * safeTileScale;
|
|
1203
|
+
const centeredX = x + (w - tileW) / 2;
|
|
1204
|
+
const centeredY = y + (h - tileH) / 2;
|
|
1205
|
+
const startX = centeredX - Math.max(0, Math.ceil((centeredX - x) / tileW)) * tileW;
|
|
1206
|
+
const startY = centeredY - Math.max(0, Math.ceil((centeredY - y) / tileH)) * tileH;
|
|
1207
|
+
let iy = startY;
|
|
1208
|
+
while (iy < y + h) {
|
|
1209
|
+
let ix = startX;
|
|
1210
|
+
while (ix < x + w) {
|
|
1211
|
+
drawImageRectLinear(image, src, CK.LTRBRect(ix, iy, ix + tileW, iy + tileH), paint);
|
|
1212
|
+
const nextX = ix + tileW;
|
|
1213
|
+
if (!(nextX > ix)) break;
|
|
1214
|
+
ix = nextX;
|
|
1215
|
+
}
|
|
1216
|
+
const nextY = iy + tileH;
|
|
1217
|
+
if (!(nextY > iy)) break;
|
|
1218
|
+
iy = nextY;
|
|
1219
|
+
}
|
|
1220
|
+
} else if (mode === 0 || mode === 2) {
|
|
1221
|
+
const scale = Math.max(w / imageW, h / imageH);
|
|
1222
|
+
const dw = imageW * scale;
|
|
1223
|
+
const dh = imageH * scale;
|
|
1224
|
+
canvas.clipRect(dst, CK.ClipOp.Intersect, true);
|
|
1225
|
+
drawImageRectLinear(image, src, CK.LTRBRect(x + (w - dw) / 2, y + (h - dh) / 2, x + (w + dw) / 2, y + (h + dh) / 2), paint);
|
|
1226
|
+
} else {
|
|
1227
|
+
drawImageRectLinear(image, src, dst, paint);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
canvas.restore();
|
|
1231
|
+
paint.delete();
|
|
1232
|
+
if (shader && shader.delete) shader.delete();
|
|
1233
|
+
if (colorFilter && colorFilter.delete) colorFilter.delete();
|
|
1234
|
+
},
|
|
1235
|
+
|
|
1236
|
+
drawText(t, family, x, y, sz, weight, italic, r, g, b, a) {
|
|
1237
|
+
if (!t) return;
|
|
1238
|
+
// Per-CHARACTER family resolution: chars the selected named face covers draw
|
|
1239
|
+
// with it; the rest fall to the script-segmented path — so a mixed run
|
|
1240
|
+
// keeps the named family where it applies and never renders tofu,
|
|
1241
|
+
// matching native. Draw + measure split on the SAME registeredCoverage
|
|
1242
|
+
// segments and share drawScriptRun/registeredFamilyFont, so advances agree.
|
|
1243
|
+
const familyEntry = familyTypefaceEntry(family);
|
|
1244
|
+
const covSegs = familyEntry ? registeredCoverageSegments(familyEntry.key, familyEntry.tf, sz, t) : null;
|
|
1245
|
+
if (!covSegs || (covSegs.length === 1 && !covSegs[0].registered)) {
|
|
1246
|
+
drawScriptRun(t, x, y, sz, weight, italic, r, g, b, a);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
const p = fillPaint(r, g, b, a);
|
|
1250
|
+
if (weight >= 600 && isPaintStyle(CK.PaintStyle.StrokeAndFill)) {
|
|
1251
|
+
setPaintStyle(p, CK.PaintStyle.StrokeAndFill);
|
|
1252
|
+
p.setStrokeWidth(sz * 0.06);
|
|
1253
|
+
}
|
|
1254
|
+
let cx = x;
|
|
1255
|
+
for (const seg of covSegs) {
|
|
1256
|
+
if (seg.registered) {
|
|
1257
|
+
const f = registeredFamilyFont(familyEntry.key, familyEntry.tf, sz, italic);
|
|
1258
|
+
canvas.drawText(seg.text, cx, y, p, f);
|
|
1259
|
+
cx += runWidth(f, seg.text);
|
|
1260
|
+
} else {
|
|
1261
|
+
cx += drawScriptRun(seg.text, cx, y, sz, weight, italic, r, g, b, a);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
},
|
|
1265
|
+
drawShapedText(t, family, x, y, sz, weight, italic, r, g, b, a) {
|
|
1266
|
+
if (!t) return;
|
|
1267
|
+
if (shapedTextUnavailable()) {
|
|
1268
|
+
// No 2D context (headless embed / hostile sandbox). The segmented
|
|
1269
|
+
// path at least paints glyphs rather than nothing, though it cannot
|
|
1270
|
+
// reorder or join them.
|
|
1271
|
+
drawScriptRun(t, x, y, sz, weight, italic, r, g, b, a);
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
drawBrowserText(t, x, y, sz, weight, italic, r, g, b, a, false, family);
|
|
1275
|
+
},
|
|
1276
|
+
measureShapedText(t, family, sz, weight, italic) {
|
|
1277
|
+
if (!t) return 0;
|
|
1278
|
+
// Must mirror `drawShapedText` exactly — measuring on one engine and
|
|
1279
|
+
// painting on another is what desyncs wrapping and caret geometry.
|
|
1280
|
+
if (shapedTextUnavailable()) {
|
|
1281
|
+
return this.measureTextFamilyStyled(t, family, sz, weight, italic);
|
|
1282
|
+
}
|
|
1283
|
+
return browserTextMeasure(t, sz, weight, italic, family);
|
|
1284
|
+
},
|
|
1285
|
+
measureText(t, sz) {
|
|
1286
|
+
return this.measureTextStyled(t, sz, 400, false);
|
|
1287
|
+
},
|
|
1288
|
+
textAscent(family, sz, weight) {
|
|
1289
|
+
const familyEntry = familyTypefaceEntry(family);
|
|
1290
|
+
const font = new CK.Font(familyEntry ? familyEntry.tf : tfFor('M', false), sz);
|
|
1291
|
+
let ascent = sz * 0.8;
|
|
1292
|
+
if (font.getMetrics) {
|
|
1293
|
+
const metrics = font.getMetrics();
|
|
1294
|
+
const candidate = metrics && Number.isFinite(metrics.ascent) ? -metrics.ascent : NaN;
|
|
1295
|
+
if (Number.isFinite(candidate) && candidate > 0) ascent = candidate;
|
|
1296
|
+
}
|
|
1297
|
+
font.delete();
|
|
1298
|
+
return ascent;
|
|
1299
|
+
},
|
|
1300
|
+
textFirstBaseline(t, family, sz, weight, italic, lineHeight) {
|
|
1301
|
+
const firstBreak = String(t || '').search(/[\r\n]/);
|
|
1302
|
+
const line = firstBreak < 0 ? String(t || '') : String(t || '').slice(0, firstBreak);
|
|
1303
|
+
const sample = line || 'M';
|
|
1304
|
+
const familyEntry = familyTypefaceEntry(family);
|
|
1305
|
+
const covSegs = familyEntry
|
|
1306
|
+
? registeredCoverageSegments(familyEntry.key, familyEntry.tf, sz, sample)
|
|
1307
|
+
: null;
|
|
1308
|
+
let ascent = 0;
|
|
1309
|
+
let descent = 0;
|
|
1310
|
+
const includeBrowserMetrics = (text) => {
|
|
1311
|
+
if (!browserTextCtx) return false;
|
|
1312
|
+
browserTextCtx.font = browserTextFont(sz, weight, italic);
|
|
1313
|
+
const metrics = browserTextCtx.measureText(text);
|
|
1314
|
+
const fontAscent = Number(metrics.fontBoundingBoxAscent);
|
|
1315
|
+
const fontDescent = Number(metrics.fontBoundingBoxDescent);
|
|
1316
|
+
const inkAscent = Number(metrics.actualBoundingBoxAscent);
|
|
1317
|
+
const inkDescent = Number(metrics.actualBoundingBoxDescent);
|
|
1318
|
+
ascent = Math.max(ascent,
|
|
1319
|
+
Number.isFinite(fontAscent) ? fontAscent : 0,
|
|
1320
|
+
Number.isFinite(inkAscent) ? inkAscent : 0);
|
|
1321
|
+
descent = Math.max(descent,
|
|
1322
|
+
Number.isFinite(fontDescent) ? fontDescent : 0,
|
|
1323
|
+
Number.isFinite(inkDescent) ? inkDescent : 0);
|
|
1324
|
+
return true;
|
|
1325
|
+
};
|
|
1326
|
+
const includeFontMetrics = (font) => {
|
|
1327
|
+
if (!font || !font.getMetrics) return;
|
|
1328
|
+
const metrics = font.getMetrics();
|
|
1329
|
+
if (!metrics) return;
|
|
1330
|
+
const candidateAscent = -Number(metrics.ascent);
|
|
1331
|
+
const candidateDescent = Number(metrics.descent);
|
|
1332
|
+
if (Number.isFinite(candidateAscent)) ascent = Math.max(ascent, candidateAscent);
|
|
1333
|
+
if (Number.isFinite(candidateDescent)) descent = Math.max(descent, candidateDescent);
|
|
1334
|
+
};
|
|
1335
|
+
const includeFallback = (text) => {
|
|
1336
|
+
for (const seg of segments(text)) {
|
|
1337
|
+
if (includeBrowserMetrics(seg.text)) continue;
|
|
1338
|
+
const font = new CK.Font(tfFor(seg.text, seg.emoji), sz);
|
|
1339
|
+
includeFontMetrics(font);
|
|
1340
|
+
font.delete();
|
|
1341
|
+
}
|
|
1342
|
+
};
|
|
1343
|
+
if (covSegs && covSegs.some((seg) => seg.registered)) {
|
|
1344
|
+
for (const seg of covSegs) {
|
|
1345
|
+
if (seg.registered) {
|
|
1346
|
+
includeFontMetrics(registeredFamilyFont(familyEntry.key, familyEntry.tf, sz, italic));
|
|
1347
|
+
} else {
|
|
1348
|
+
includeFallback(seg.text);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
} else {
|
|
1352
|
+
includeFallback(sample);
|
|
1353
|
+
}
|
|
1354
|
+
if (!(ascent > 0)) ascent = sz * 0.8;
|
|
1355
|
+
if (!(descent > 0)) descent = sz * 0.2;
|
|
1356
|
+
const naturalHeight = ascent + descent;
|
|
1357
|
+
const targetHeight = lineHeight > 0 ? sz * lineHeight : naturalHeight;
|
|
1358
|
+
const baseline = ascent + (targetHeight - naturalHeight) / 2;
|
|
1359
|
+
return Number.isFinite(baseline) && baseline > 0 ? baseline : sz * 0.8;
|
|
1360
|
+
},
|
|
1361
|
+
measureTextFamilyStyled(t, family, sz, weight, italic) {
|
|
1362
|
+
const familyEntry = familyTypefaceEntry(family);
|
|
1363
|
+
const covSegs = familyEntry ? registeredCoverageSegments(familyEntry.key, familyEntry.tf, sz, t) : null;
|
|
1364
|
+
if (!covSegs || (covSegs.length === 1 && !covSegs[0].registered)) {
|
|
1365
|
+
// No registered family (or it covers nothing): the family-blind
|
|
1366
|
+
// script-segmented measure — the SAME path drawText falls to.
|
|
1367
|
+
return this.measureTextStyled(t, sz, weight, italic);
|
|
1368
|
+
}
|
|
1369
|
+
let w = 0;
|
|
1370
|
+
for (const seg of covSegs) {
|
|
1371
|
+
if (seg.registered) {
|
|
1372
|
+
const f = registeredFamilyFont(familyEntry.key, familyEntry.tf, sz, italic);
|
|
1373
|
+
w += runWidth(f, seg.text);
|
|
1374
|
+
} else {
|
|
1375
|
+
w += this.measureTextStyled(seg.text, sz, weight, italic);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
return w;
|
|
1379
|
+
},
|
|
1380
|
+
measureTextStyled(t, sz, weight, italic) {
|
|
1381
|
+
let w = 0;
|
|
1382
|
+
for (const seg of segments(t)) {
|
|
1383
|
+
if (shouldUseBrowserTextFallback(seg.text, seg.emoji)) {
|
|
1384
|
+
w += browserTextMeasure(seg.text, sz, weight, italic);
|
|
1385
|
+
continue;
|
|
1386
|
+
}
|
|
1387
|
+
const f = new CK.Font(tfFor(seg.text, seg.emoji), sz);
|
|
1388
|
+
if (italic && !seg.emoji) f.setSkewX(-0.25);
|
|
1389
|
+
w += runWidth(f, seg.text);
|
|
1390
|
+
f.delete();
|
|
1391
|
+
}
|
|
1392
|
+
return w;
|
|
1393
|
+
},
|
|
1394
|
+
/// Measure a multi-run paragraph with optional line-wrapping and return
|
|
1395
|
+
/// [width, height, line_count, baseline] to match the layout engine's contract.
|
|
1396
|
+
///
|
|
1397
|
+
/// Runs are arrays passed from Rust: texts[], families[], sizes[], weights[],
|
|
1398
|
+
/// italics[] (u8 bools), spacing[] (letter_spacing per run).
|
|
1399
|
+
/// maxWidth is the wrap budget; -1 means natural single-line extent.
|
|
1400
|
+
/// lineHeight is the multiplier (0 = engine default = 1.3).
|
|
1401
|
+
///
|
|
1402
|
+
/// Wrapping strategy: measure each run segment-by-segment (same as the
|
|
1403
|
+
/// design-canvas draw path), break on newlines, and when a run exceeds the
|
|
1404
|
+
/// budget, measure character by character to find the break point.
|
|
1405
|
+
measureParagraph(texts, families, sizes, weights, italics, spacing, maxWidth, lineHeight) {
|
|
1406
|
+
if (!texts || texts.length === 0) {
|
|
1407
|
+
return Float32Array.of(0, 0, 0, 0);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// Collect runs into a flat array structure.
|
|
1411
|
+
const runs = [];
|
|
1412
|
+
let hasNewline = false;
|
|
1413
|
+
for (let i = 0; i < texts.length; i++) {
|
|
1414
|
+
const text = String(texts[i] || '');
|
|
1415
|
+
const family = String(families[i] || '');
|
|
1416
|
+
const sz = Number(sizes[i]) || 16;
|
|
1417
|
+
const weight = Number(weights[i]) || 400;
|
|
1418
|
+
const italic = Boolean(italics[i]);
|
|
1419
|
+
const letterSpacing = Number(spacing[i]) || 0;
|
|
1420
|
+
if (text.includes('\n')) hasNewline = true;
|
|
1421
|
+
runs.push({ text, family, sz, weight, italic, letterSpacing });
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// Compute baseline and natural height metrics from the largest/heaviest run.
|
|
1425
|
+
let maxSz = 0;
|
|
1426
|
+
let ascent = 0;
|
|
1427
|
+
let descent = 0;
|
|
1428
|
+
for (const run of runs) {
|
|
1429
|
+
maxSz = Math.max(maxSz, run.sz);
|
|
1430
|
+
if (browserTextCtx) {
|
|
1431
|
+
browserTextCtx.font = browserTextFont(run.sz, run.weight, run.italic, run.family);
|
|
1432
|
+
const metrics = browserTextCtx.measureText('M');
|
|
1433
|
+
const fontAsc = Number(metrics.fontBoundingBoxAscent);
|
|
1434
|
+
const fontDesc = Number(metrics.fontBoundingBoxDescent);
|
|
1435
|
+
const inkAsc = Number(metrics.actualBoundingBoxAscent);
|
|
1436
|
+
const inkDesc = Number(metrics.actualBoundingBoxDescent);
|
|
1437
|
+
ascent = Math.max(ascent,
|
|
1438
|
+
Number.isFinite(fontAsc) ? fontAsc : 0,
|
|
1439
|
+
Number.isFinite(inkAsc) ? inkAsc : 0);
|
|
1440
|
+
descent = Math.max(descent,
|
|
1441
|
+
Number.isFinite(fontDesc) ? fontDesc : 0,
|
|
1442
|
+
Number.isFinite(inkDesc) ? inkDesc : 0);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
if (!(ascent > 0)) ascent = maxSz * 0.8;
|
|
1446
|
+
if (!(descent > 0)) descent = maxSz * 0.2;
|
|
1447
|
+
const naturalLineHeight = ascent + descent;
|
|
1448
|
+
const finalLineHeight = lineHeight > 0 ? maxSz * lineHeight : naturalLineHeight;
|
|
1449
|
+
const baseline = ascent + (finalLineHeight - naturalLineHeight) / 2;
|
|
1450
|
+
|
|
1451
|
+
// Helper to measure a single run with explicit text, sz, weight, italic, family.
|
|
1452
|
+
const measureRunWidth = (runText, runSz, runWeight, runItalic, runFamily) => {
|
|
1453
|
+
return this.measureTextFamilyStyled(runText, runFamily, runSz, runWeight, runItalic);
|
|
1454
|
+
};
|
|
1455
|
+
|
|
1456
|
+
// If no max width or no newlines and no need to wrap, return natural width.
|
|
1457
|
+
if (maxWidth < 0) {
|
|
1458
|
+
let w = 0;
|
|
1459
|
+
for (const run of runs) {
|
|
1460
|
+
w += measureRunWidth(run.text, run.sz, run.weight, run.italic, run.family);
|
|
1461
|
+
// Add letter spacing: # of chars * spacing.
|
|
1462
|
+
if (run.letterSpacing !== 0) {
|
|
1463
|
+
w += run.text.length * run.letterSpacing;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
// Count explicit lines from newlines.
|
|
1467
|
+
let lineCount = 1;
|
|
1468
|
+
for (const run of runs) {
|
|
1469
|
+
lineCount += (run.text.match(/\n/g) || []).length;
|
|
1470
|
+
}
|
|
1471
|
+
const height = maxSz * (lineHeight > 0 ? lineHeight : 1.3) * lineCount;
|
|
1472
|
+
return Float32Array.of(w, height, lineCount, baseline);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// Wrapping logic: split runs into lines based on maxWidth.
|
|
1476
|
+
// Strategy mirrors the existing code around line 1375 — measure each run,
|
|
1477
|
+
// break on newlines, and when a run exceeds the budget, split it.
|
|
1478
|
+
const lines = [];
|
|
1479
|
+
let currentLine = [];
|
|
1480
|
+
let currentLineWidth = 0;
|
|
1481
|
+
|
|
1482
|
+
for (const run of runs) {
|
|
1483
|
+
// Split on newlines first.
|
|
1484
|
+
const chunks = run.text.split('\n');
|
|
1485
|
+
for (let chunkIdx = 0; chunkIdx < chunks.length; chunkIdx++) {
|
|
1486
|
+
const chunk = chunks[chunkIdx];
|
|
1487
|
+
const isLastChunk = chunkIdx === chunks.length - 1;
|
|
1488
|
+
|
|
1489
|
+
if (chunk) {
|
|
1490
|
+
// Measure this chunk with wrapping if needed.
|
|
1491
|
+
const chunkWidth = measureRunWidth(chunk, run.sz, run.weight, run.italic, run.family);
|
|
1492
|
+
const totalChunkWidth = chunkWidth + chunk.length * run.letterSpacing;
|
|
1493
|
+
|
|
1494
|
+
if (currentLineWidth + totalChunkWidth <= maxWidth) {
|
|
1495
|
+
// Fits on current line.
|
|
1496
|
+
currentLine.push({ text: chunk, width: chunkWidth, spacing: run.letterSpacing });
|
|
1497
|
+
currentLineWidth += totalChunkWidth;
|
|
1498
|
+
} else if (currentLineWidth > 0) {
|
|
1499
|
+
// Doesn't fit; start a new line if there's anything on the current line.
|
|
1500
|
+
lines.push(currentLine);
|
|
1501
|
+
currentLine = [{ text: chunk, width: chunkWidth, spacing: run.letterSpacing }];
|
|
1502
|
+
currentLineWidth = totalChunkWidth;
|
|
1503
|
+
} else {
|
|
1504
|
+
// Current line is empty but chunk still doesn't fit: break chunk.
|
|
1505
|
+
let remaining = chunk;
|
|
1506
|
+
while (remaining) {
|
|
1507
|
+
let fit = '';
|
|
1508
|
+
let fitWidth = 0;
|
|
1509
|
+
for (const ch of remaining) {
|
|
1510
|
+
const candidate = fit + ch;
|
|
1511
|
+
const candidateWidth = measureRunWidth(candidate, run.sz, run.weight, run.italic, run.family);
|
|
1512
|
+
const totalWidth = candidateWidth + candidate.length * run.letterSpacing;
|
|
1513
|
+
if (totalWidth <= maxWidth) {
|
|
1514
|
+
fit = candidate;
|
|
1515
|
+
fitWidth = candidateWidth;
|
|
1516
|
+
} else {
|
|
1517
|
+
break;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
if (!fit && remaining) {
|
|
1521
|
+
// Degenerate: even one char doesn't fit; output it anyway.
|
|
1522
|
+
fit = remaining[0];
|
|
1523
|
+
fitWidth = measureRunWidth(fit, run.sz, run.weight, run.italic, run.family);
|
|
1524
|
+
}
|
|
1525
|
+
if (fit) {
|
|
1526
|
+
lines.push([{ text: fit, width: fitWidth, spacing: run.letterSpacing }]);
|
|
1527
|
+
remaining = remaining.slice(fit.length);
|
|
1528
|
+
} else {
|
|
1529
|
+
break;
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
currentLine = [];
|
|
1533
|
+
currentLineWidth = 0;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
if (!isLastChunk) {
|
|
1538
|
+
// Newline: close the current line.
|
|
1539
|
+
if (currentLine.length > 0) {
|
|
1540
|
+
lines.push(currentLine);
|
|
1541
|
+
}
|
|
1542
|
+
currentLine = [];
|
|
1543
|
+
currentLineWidth = 0;
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
if (currentLine.length > 0) {
|
|
1549
|
+
lines.push(currentLine);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// Compute final metrics.
|
|
1553
|
+
const lineCount = Math.max(1, lines.length);
|
|
1554
|
+
const width = maxWidth;
|
|
1555
|
+
const height = finalLineHeight * lineCount;
|
|
1556
|
+
|
|
1557
|
+
return Float32Array.of(width, height, lineCount, baseline);
|
|
1558
|
+
},
|
|
1559
|
+
registerSystemFont(family, bytes) {
|
|
1560
|
+
const key = normalizedFamily(family);
|
|
1561
|
+
if (!key || systemTypefaceKeys.has(key)) return Boolean(key);
|
|
1562
|
+
const tf = CK.Typeface.MakeFreeTypeFaceFromData(copyBytes(bytes));
|
|
1563
|
+
if (!tf) return false;
|
|
1564
|
+
const entry = {
|
|
1565
|
+
key,
|
|
1566
|
+
family: String(family || ''),
|
|
1567
|
+
tf,
|
|
1568
|
+
cjk: isCjkFamily(family),
|
|
1569
|
+
emoji: isEmojiFamily(family),
|
|
1570
|
+
textFallback: isTextFallbackFamily(family),
|
|
1571
|
+
};
|
|
1572
|
+
systemTypefaceKeys.add(key);
|
|
1573
|
+
systemTypefaces.push(entry);
|
|
1574
|
+
systemTypefacesByFamily.set(key, entry);
|
|
1575
|
+
coverageCache.clear();
|
|
1576
|
+
clearRegisteredFontCaches();
|
|
1577
|
+
return true;
|
|
1578
|
+
},
|
|
1579
|
+
registerImportedFont(family, bytes) {
|
|
1580
|
+
const key = primaryFamilyKey(family);
|
|
1581
|
+
if (!key) return false;
|
|
1582
|
+
const tf = CK.Typeface.MakeFreeTypeFaceFromData(copyBytes(bytes));
|
|
1583
|
+
if (!tf) return false;
|
|
1584
|
+
// Replace any prior face under the same key, freeing its wasm-heap tf.
|
|
1585
|
+
const prev = importedTypefaces.get(key);
|
|
1586
|
+
if (prev && prev.tf && prev.tf.delete) prev.tf.delete();
|
|
1587
|
+
importedTypefaces.set(key, { tf, family: String(family || '') });
|
|
1588
|
+
coverageCache.clear();
|
|
1589
|
+
clearRegisteredFontCaches();
|
|
1590
|
+
return true;
|
|
1591
|
+
},
|
|
1592
|
+
// (Fresh browser imports parse the family name in Rust via `ttf-parser` —
|
|
1593
|
+
// the vendored CanvasKit build exposes no family-name API — then register
|
|
1594
|
+
// through `registerImportedFont` above with the known family.)
|
|
1595
|
+
// The display names of every registered imported family, so the Rust snapshot
|
|
1596
|
+
// (which doesn't own the registry on web) can mirror the picker's Imported
|
|
1597
|
+
// group after every add / remove and at mount.
|
|
1598
|
+
importedFamilyList() {
|
|
1599
|
+
const out = [];
|
|
1600
|
+
for (const entry of importedTypefaces.values()) {
|
|
1601
|
+
if (entry && entry.family) out.push(entry.family);
|
|
1602
|
+
}
|
|
1603
|
+
return out;
|
|
1604
|
+
},
|
|
1605
|
+
removeImportedFont(family) {
|
|
1606
|
+
const key = primaryFamilyKey(family);
|
|
1607
|
+
if (!key) return;
|
|
1608
|
+
const entry = importedTypefaces.get(key);
|
|
1609
|
+
if (!entry) return;
|
|
1610
|
+
if (entry.tf && entry.tf.delete) entry.tf.delete();
|
|
1611
|
+
importedTypefaces.delete(key);
|
|
1612
|
+
clearRegisteredFontCaches();
|
|
1613
|
+
},
|
|
1614
|
+
|
|
1615
|
+
clipRect(x, y, w, h) { canvas.clipRect(CK.LTRBRect(x, y, x + w, y + h), CK.ClipOp.Intersect, true); },
|
|
1616
|
+
clipRoundRect(x, y, w, h, rad) { canvas.clipRRect(CK.RRectXY(CK.LTRBRect(x, y, x + w, y + h), rad, rad), CK.ClipOp.Intersect, true); },
|
|
1617
|
+
clipRoundRectPerCorner(x, y, w, h, tl, tr, br, bl) {
|
|
1618
|
+
const rr = Float32Array.of(x, y, x + w, y + h, tl, tl, tr, tr, br, br, bl, bl);
|
|
1619
|
+
canvas.clipRRect(rr, CK.ClipOp.Intersect, true);
|
|
1620
|
+
},
|
|
1621
|
+
clipOval(x, y, w, h) {
|
|
1622
|
+
const path = new CK.Path();
|
|
1623
|
+
path.addOval(CK.LTRBRect(x, y, x + w, y + h));
|
|
1624
|
+
canvas.clipPath(path, CK.ClipOp.Intersect, true);
|
|
1625
|
+
path.delete();
|
|
1626
|
+
},
|
|
1627
|
+
clipPolygon(pts) {
|
|
1628
|
+
if (pts.length < 6) {
|
|
1629
|
+
canvas.clipRect(CK.LTRBRect(0, 0, 0, 0), CK.ClipOp.Intersect, true);
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
const path = new CK.Path(); path.moveTo(pts[0], pts[1]);
|
|
1633
|
+
for (let i = 2; i < pts.length; i += 2) path.lineTo(pts[i], pts[i + 1]);
|
|
1634
|
+
path.close();
|
|
1635
|
+
canvas.clipPath(path, CK.ClipOp.Intersect, true);
|
|
1636
|
+
path.delete();
|
|
1637
|
+
},
|
|
1638
|
+
clipSvgPathInRect(d, x, y, w, h, evenOdd) {
|
|
1639
|
+
const path = cachedSvgPath(d);
|
|
1640
|
+
if (!path) {
|
|
1641
|
+
canvas.clipRect(CK.LTRBRect(0, 0, 0, 0), CK.ClipOp.Intersect, true);
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
if (evenOdd) path.setFillType(CK.FillType.EvenOdd);
|
|
1645
|
+
fitPathToRect(path, x, y, w, h);
|
|
1646
|
+
canvas.clipPath(path, CK.ClipOp.Intersect, true);
|
|
1647
|
+
path.delete();
|
|
1648
|
+
},
|
|
1649
|
+
save() { canvas.save(); },
|
|
1650
|
+
pushCompositeLayer(x, y, w, h, opacity, blendMode) {
|
|
1651
|
+
const paint = new CK.Paint();
|
|
1652
|
+
paint.setAlphaf(Math.max(0, Math.min(1, opacity)));
|
|
1653
|
+
paint.setBlendMode(blendModeForCode(blendMode));
|
|
1654
|
+
canvas.saveLayer(paint, CK.LTRBRect(x, y, x + w, y + h));
|
|
1655
|
+
paint.delete();
|
|
1656
|
+
},
|
|
1657
|
+
pushMaskSourceLayer(luminance) {
|
|
1658
|
+
const paint = new CK.Paint();
|
|
1659
|
+
paint.setBlendMode(CK.BlendMode.DstIn);
|
|
1660
|
+
const luma = luminance && CK.ColorFilter && CK.ColorFilter.MakeLuma
|
|
1661
|
+
? CK.ColorFilter.MakeLuma()
|
|
1662
|
+
: null;
|
|
1663
|
+
if (luma) paint.setColorFilter(luma);
|
|
1664
|
+
canvas.saveLayer(paint);
|
|
1665
|
+
paint.delete();
|
|
1666
|
+
if (luma && luma.delete) luma.delete();
|
|
1667
|
+
},
|
|
1668
|
+
pushBlendLayer(blendMode) {
|
|
1669
|
+
if (!blendMode) {
|
|
1670
|
+
canvas.save();
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
const paint = new CK.Paint();
|
|
1674
|
+
paint.setBlendMode(blendModeForCode(blendMode));
|
|
1675
|
+
canvas.saveLayer(paint);
|
|
1676
|
+
paint.delete();
|
|
1677
|
+
},
|
|
1678
|
+
pushBackdropBlurLayer(sigma) {
|
|
1679
|
+
if (!(sigma > 0) || !CK.ImageFilter || !CK.ImageFilter.MakeBlur) {
|
|
1680
|
+
canvas.save();
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
const filter = CK.ImageFilter.MakeBlur(sigma, sigma, CK.TileMode.Clamp, null);
|
|
1684
|
+
if (!filter) {
|
|
1685
|
+
canvas.save();
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
canvas.saveLayer(null, null, filter, 0, CK.TileMode.Clamp);
|
|
1689
|
+
if (filter.delete) filter.delete();
|
|
1690
|
+
},
|
|
1691
|
+
restore() { canvas.restore(); },
|
|
1692
|
+
translate(x, y) { canvas.translate(x, y); },
|
|
1693
|
+
scale(sx, sy) { canvas.scale(sx, sy); },
|
|
1694
|
+
rotate(deg, px, py) { canvas.rotate(deg, px, py); },
|
|
1695
|
+
|
|
1696
|
+
resize(w, h) {
|
|
1697
|
+
el.width = w; el.height = h;
|
|
1698
|
+
try { surface.delete(); } catch (e) {}
|
|
1699
|
+
surface = CK.MakeWebGLCanvasSurface(canvasId);
|
|
1700
|
+
canvas = surface.getCanvas();
|
|
1701
|
+
},
|
|
1702
|
+
// Set the device-pixel-ratio used to supersample the offscreen text raster.
|
|
1703
|
+
// Called from Rust on mount + every display resize so glyph bitmaps stay
|
|
1704
|
+
// crisp on HiDPI screens.
|
|
1705
|
+
setDpr(v) {
|
|
1706
|
+
const next = Number.isFinite(v) && v > 0 ? Math.max(1, v) : 1;
|
|
1707
|
+
if (next === textDpr) return;
|
|
1708
|
+
textDpr = next;
|
|
1709
|
+
clearBrowserTextCache();
|
|
1710
|
+
},
|
|
1711
|
+
};
|
|
1712
|
+
}
|