///|
priv struct SkiaGlyphRun {
glyphs : Array[@skia_native.GlyphId]
positions : Array[@moui_skia.Point]
clusters : Array[Int]
width : Double
}
///|
fn skia_measure_text_width(font : @skia_native.Font, text : String) -> Double {
font.measure_text_utf8(@utf8.encode(text[:], bom=false)).to_double()
}
///|
fn skia_draw_text_glyph_run(
font : @skia_native.Font,
text : String,
) -> SkiaGlyphRun {
skia_glyph_run_with_shaper(font, text, use_shaper=false)
}
///|
fn skia_glyph_run_for_resolution(
font : @skia_native.Font,
text : String,
resolution : SkiaFontResolution,
) -> SkiaGlyphRun {
match resolution {
SystemFontMgr => skia_glyph_run_with_shaper(font, text, use_shaper=true)
EmptyTypeface => skia_draw_text_glyph_run(font, text)
}
}
///|
fn skia_glyph_run_with_shaper(
font : @skia_native.Font,
text : String,
use_shaper~ : Bool,
) -> SkiaGlyphRun {
let bytes = @utf8.encode(text[:], bom=false)
if use_shaper && @skia_native.skia_shaper_available() {
match font.shape_text_utf8(bytes) {
Some(run) => {
let glyphs = run.glyphs()
let positions = run.positions()
if !glyphs.is_empty() && glyphs.length() == positions.length() {
let clusters = run.clusters()
if clusters.length() == glyphs.length() {
return {
glyphs,
positions,
clusters,
width: run.advance().x.to_double(),
}
}
}
}
None => ()
}
}
let glyphs = font.text_to_glyphs_utf8(bytes)
{
glyphs,
positions: font.glyph_positions(glyphs),
clusters: [],
width: font.measure_text_utf8(bytes).to_double(),
}
}
///|
fn skia_glyph_run_has_visible_glyphs(run : SkiaGlyphRun) -> Bool {
if run.glyphs.is_empty() || run.width <= 0.0 {
return false
}
run.glyphs.any(glyph => glyph.to_int() != 0)
}
///|
fn skia_glyph_run_missing_glyph_count(run : SkiaGlyphRun) -> Int {
let mut count = 0
for glyph in run.glyphs {
if glyph.to_int() == 0 {
count = count + 1
}
}
count
}
///|
fn skia_glyph_run_can_draw_text(run : SkiaGlyphRun) -> Bool {
skia_glyph_run_has_visible_glyphs(run) &&
skia_glyph_run_missing_glyph_count(run) == 0
}
///|
fn skia_glyph_run_improves_missing_glyphs(
primary_run : SkiaGlyphRun,
fallback_run : SkiaGlyphRun,
) -> Bool {
if !skia_glyph_run_has_visible_glyphs(fallback_run) {
return false
}
if !skia_glyph_run_has_visible_glyphs(primary_run) {
return true
}
skia_glyph_run_missing_glyph_count(fallback_run) <
skia_glyph_run_missing_glyph_count(primary_run)
}
///|
fn skia_fallback_draw_payload(
font : @core.FontSpec,
text : String,
resolution : SkiaFontResolution,
primary_run : SkiaGlyphRun,
) -> (@skia_native.Font, SkiaGlyphRun)? {
match skia_emoji_fallback_draw_font(font, text, resolution) {
Some(emoji_font) =>
match skia_complete_fallback_payload(emoji_font, text, resolution) {
Some(payload) => return Some(payload)
None => ()
}
None => ()
}
match skia_default_fallback_draw_font(font) {
Some(fallback_font) =>
skia_recovering_fallback_payload(
fallback_font, text, resolution, primary_run,
)
None => None
}
}
///|
fn skia_complete_fallback_payload(
font : @skia_native.Font,
text : String,
resolution : SkiaFontResolution,
) -> (@skia_native.Font, SkiaGlyphRun)? {
let fallback_run = skia_glyph_run_for_resolution(font, text, resolution)
if skia_glyph_run_can_draw_text(fallback_run) {
Some((font, fallback_run))
} else {
None
}
}
///|
fn skia_recovering_fallback_payload(
font : @skia_native.Font,
text : String,
resolution : SkiaFontResolution,
primary_run : SkiaGlyphRun,
) -> (@skia_native.Font, SkiaGlyphRun)? {
let fallback_run = skia_glyph_run_for_resolution(font, text, resolution)
if skia_glyph_run_can_draw_text(fallback_run) ||
skia_glyph_run_improves_missing_glyphs(primary_run, fallback_run) {
Some((font, fallback_run))
} else {
None
}
}
///|
fn skia_emoji_fallback_draw_font(
font : @core.FontSpec,
text : String,
resolution : SkiaFontResolution,
) -> @skia_native.Font? {
match resolution {
SystemFontMgr =>
if skia_text_contains_emoji_hint(text) {
skia_system_emoji_font(font)
} else {
None
}
EmptyTypeface => None
}
}
///|
fn skia_system_emoji_font(font : @core.FontSpec) -> @skia_native.Font? {
match @skia_native.FontMgr::default() {
Some(font_mgr) => {
let emoji_font = @core.FontSpec::new(
families=@core.FontFamilyStack::new([Emoji]),
size=font.size,
weight=font.weight,
style=font.style,
)
match skia_typeface_from_font_mgr(font_mgr, emoji_font) {
Some(typeface) =>
@skia_native.Font::from_typeface(
typeface,
size=Float::from_double(font.size),
)
None => None
}
}
None => None
}
}
///|
fn skia_default_fallback_draw_font(font : @core.FontSpec) -> @skia_native.Font? {
@skia_native.Font::default(size=Float::from_double(font.size))
}
///|
fn skia_text_contains_emoji_hint(text : String) -> Bool {
for ch in text {
if @render_common.codepoint_is_emoji_hint(ch.to_int()) {
return true
}
}
false
}
///|
fn skia_codepoint_is_hebrew_script(codepoint : Int) -> Bool {
codepoint >= 0x0590 && codepoint <= 0x05FF
}
///|
fn skia_codepoint_is_arabic_script(codepoint : Int) -> Bool {
(codepoint >= 0x0600 && codepoint <= 0x08FF) ||
(codepoint >= 0xFB50 && codepoint <= 0xFDFF) ||
(codepoint >= 0xFE70 && codepoint <= 0xFEFF)
}
///|
fn skia_codepoint_is_devanagari_script(codepoint : Int) -> Bool {
codepoint >= 0x0900 && codepoint <= 0x097F
}
///|
fn skia_codepoint_is_thai_script(codepoint : Int) -> Bool {
codepoint >= 0x0E00 && codepoint <= 0x0E7F
}
///|
fn skia_codepoint_is_lao_script(codepoint : Int) -> Bool {
codepoint >= 0x0E80 && codepoint <= 0x0EFF
}
///|
fn skia_codepoint_is_sinhala_script(codepoint : Int) -> Bool {
codepoint >= 0x0D80 && codepoint <= 0x0DFF
}
///|
fn skia_codepoint_is_khmer_script(codepoint : Int) -> Bool {
codepoint >= 0x1780 && codepoint <= 0x17FF
}
///|
fn skia_codepoint_is_myanmar_script(codepoint : Int) -> Bool {
codepoint >= 0x1000 && codepoint <= 0x109F
}
///|
fn skia_codepoint_is_latin_script(codepoint : Int) -> Bool {
(codepoint >= 0x0041 && codepoint <= 0x005A) ||
(codepoint >= 0x0061 && codepoint <= 0x007A) ||
(codepoint >= 0x00C0 && codepoint <= 0x024F) ||
(codepoint >= 0x1E00 && codepoint <= 0x1EFF)
}
///|
/// A font paints a shaped run with its own typeface, so glyph ids from a
/// shaper's fallback fonts are meaningless when mapped through a different
/// primary font. Accept a candidate font only when its own cmap maps every
/// character, which guarantees shaped ids belong to the drawing typeface.
fn skia_font_covers_text(font : @skia_native.Font, text : String) -> Bool {
if text == "" {
return true
}
let glyphs = font.text_to_glyphs_utf8(@utf8.encode(text[:], bom=false))
!glyphs.is_empty() && glyphs.all(glyph => glyph.to_int() != 0)
}
///|
fn skia_typeface_covers_text(
typeface : @skia_native.Typeface,
text : String,
) -> Bool {
match @skia_native.Font::from_typeface(typeface, size=16.0) {
None => false
Some(font) => skia_font_covers_text(font, text)
}
}
///|
fn skia_font_with_resolution_for_text(
font : @core.FontSpec,
text : String,
resolution : SkiaFontResolution,
) -> @skia_native.Font? {
match resolution {
EmptyTypeface =>
@skia_native.Font::empty(size=Float::from_double(font.size))
SystemFontMgr =>
match skia_typeface_for_text(font, text) {
Some(typeface) => {
let typeface = match
typeface.with_variations(skia_variation_pairs(font)) {
Some(varied) => varied
None => typeface
}
@skia_native.Font::from_typeface(
typeface,
size=Float::from_double(font.size),
)
}
None => @skia_native.Font::default(size=Float::from_double(font.size))
}
}
}
///|
fn skia_typeface_for_text(
font : @core.FontSpec,
text : String,
) -> @skia_native.Typeface? {
match skia_typeface_from_embedded(font) {
Some(typeface) if skia_typeface_covers_text(typeface, text) =>
return Some(typeface)
_ => ()
}
match skia_typeface_from_font_files(font) {
Some(typeface) if skia_typeface_covers_text(typeface, text) =>
return Some(typeface)
_ => ()
}
match @skia_native.FontMgr::default() {
Some(font_mgr) => skia_typeface_from_font_mgr_for_text(font_mgr, font, text)
None => None
}
}
///|
/// Memoized answer of the system font manager's fallback query.
///
/// `match_fallback_request` reaches CoreText, which resolves a fallback through
/// a synchronous XPC round trip to the macOS font daemon (seen as
/// `SkFontMgr_Mac::onMatchFamilyStyleCharacter` -> `CTFontCreateForString` ->
/// `XTCopyPropertiesForFont`). The answer is a pure function of the family
/// chain, the text's BCP-47 tags, one representative codepoint and the style,
/// so querying once per text segment recomputed the same handful of answers
/// thousands of times. On a large document that dominated rendering outright —
/// a CRLF file sends *every* segment down this path, because the trailing `\r`
/// is not covered by the primary font.
priv struct SkiaFallbackMemo {
request : @moui_skia.FontFallbackRequest
typeface : @skia_native.Typeface?
}
///|
/// A short scan rather than a hash map. `FontFallbackRequest` derives `Eq` but
/// not `Hash`, and the obvious workaround — keying on `@debug.to_string` — cost
/// more than the query it was caching: the printer walked the family chain and
/// the BCP-47 byte arrays once per text segment and showed up as the top frame
/// while a large document opened. The request count is small (one per script
/// and style in play), so comparing against a bounded list is both correct and
/// far cheaper.
let skia_fallback_memo : Ref[Map[Int, Array[SkiaFallbackMemo]]] = Ref(Map([]))
///|
/// Bucketed by the representative codepoint, which is what actually varies
/// between text segments; the family chain and style are near-constant for a
/// given font, so a bucket holds only a handful of entries and the full
/// request comparison stays cheap.
///
/// A single bounded list was tried first and thrashed: a novel uses thousands
/// of distinct characters, so any bound small enough to scan linearly was also
/// small enough to evict entries that the next line needed again — leaving the
/// synchronous font-daemon round trip on the hot path.
fn skia_cached_fallback_typeface(
font_mgr : @skia_native.FontMgr,
request : @moui_skia.FontFallbackRequest,
) -> @skia_native.Typeface? {
let bucket = match skia_fallback_memo.val.get(request.character) {
Some(bucket) => bucket
None => {
let fresh : Array[SkiaFallbackMemo] = []
skia_fallback_memo.val.set(request.character, fresh)
fresh
}
}
for memo in bucket {
if memo.request == request {
return memo.typeface
}
}
let typeface = font_mgr.match_fallback_request(request)
bucket.push({ request, typeface, })
typeface
}
///|
fn skia_typeface_from_font_mgr_for_text(
font_mgr : @skia_native.FontMgr,
font : @core.FontSpec,
text : String,
) -> @skia_native.Typeface? {
let request = skia_font_fallback_request(font, text)
if !request.is_empty() {
match skia_cached_fallback_typeface(font_mgr, request) {
Some(typeface) if skia_typeface_covers_text(typeface, text) =>
return Some(typeface)
_ => ()
}
}
skia_typeface_from_font_mgr(font_mgr, font)
}
///|
fn skia_typeface_from_font_mgr(
font_mgr : @skia_native.FontMgr,
font : @core.FontSpec,
) -> @skia_native.Typeface? {
for family in font.families.families {
for family_name in skia_font_family_candidates(family) {
match
font_mgr.match_family_style(
@utf8.encode(family_name[:], bom=false),
weight=font.weight,
width=5,
slant=skia_font_slant(font.style),
) {
Some(typeface) => return Some(typeface)
None => ()
}
}
}
None
}
///|
fn skia_font_fallback_request(
font : @core.FontSpec,
text : String,
) -> @moui_skia.FontFallbackRequest {
@moui_skia.FontFallbackRequest::new(
families=skia_font_fallback_chain(font),
bcp47=skia_font_fallback_language_bytes(text),
character=skia_representative_fallback_codepoint(text),
style=skia_font_style_request(font),
)
}
///|
fn skia_font_fallback_language_bytes(text : String) -> Array[Bytes] {
let tags = skia_font_fallback_language_tags(text)
let bytes : Array[Bytes] = []
for tag in tags {
bytes.push(@utf8.encode(tag[:], bom=false))
}
bytes
}
///|
fn skia_font_fallback_language_tags(text : String) -> Array[String] {
let tags : Array[String] = []
let representative = skia_representative_fallback_codepoint(text)
match skia_script_language_tag_for_codepoint(representative) {
Some(tag) => skia_push_language_tag(tags, tag)
None => ()
}
if representative > 0 {
skia_push_language_tag(tags, "en")
}
tags
}
///|
fn skia_push_language_tag(tags : Array[String], tag : String) -> Unit {
if tag != "" && !tags.contains(tag) {
tags.push(tag)
}
}
///|
fn skia_script_language_tag_for_codepoint(codepoint : Int) -> String? {
if codepoint <= 0 {
None
} else if @render_common.codepoint_is_emoji_hint(codepoint) {
Some("und-Zsye")
} else if @render_common.codepoint_is_kana(codepoint) {
Some("ja")
} else if @render_common.codepoint_is_cjk_ideograph(codepoint) {
Some("zh-Hans")
} else if @render_common.codepoint_is_hangul_cluster_codepoint(codepoint) ||
@render_common.codepoint_is_hangul_compatibility_jamo(codepoint) {
Some("ko")
} else if skia_codepoint_is_hebrew_script(codepoint) {
Some("he")
} else if skia_codepoint_is_arabic_script(codepoint) {
Some("ar")
} else if skia_codepoint_is_devanagari_script(codepoint) {
Some("hi")
} else if skia_codepoint_is_thai_script(codepoint) {
Some("th")
} else if skia_codepoint_is_lao_script(codepoint) {
Some("lo")
} else if skia_codepoint_is_sinhala_script(codepoint) {
Some("si")
} else if skia_codepoint_is_khmer_script(codepoint) {
Some("km")
} else if skia_codepoint_is_myanmar_script(codepoint) {
Some("my")
} else if skia_codepoint_is_latin_script(codepoint) {
Some("en")
} else {
None
}
}
///|
fn skia_font_fallback_chain(
font : @core.FontSpec,
) -> @moui_skia.FontFallbackChain {
let families = skia_preflight_family_candidates(font)
let primary_family = if families.is_empty() {
b"system-ui"
} else {
@utf8.encode(families[0][:], bom=false)
}
let fallback_families : Array[Bytes] = []
if families.length() > 1 {
for index in 1.. @moui_skia.FontStyleRequest {
@moui_skia.FontStyleRequest::new(
weight=font.weight,
width=5,
slant=skia_font_slant(font.style),
)
}
///|
fn skia_font_family_candidates(family : @core.FontFamily) -> Array[String] {
match family {
Name(name) => [name]
SystemUi | UiSansSerif | SansSerif => skia_system_sans_families()
UiSerif | Serif => skia_serif_families()
UiMonospace | Monospace => skia_monospace_families()
Emoji => skia_emoji_families()
Math => skia_serif_families()
FangSong => skia_fangsong_families()
}
}
///|
fn skia_system_sans_families() -> Array[String] {
[
"Helvetica Neue", "Segoe UI", "Arial", "HarmonyOS Sans", "HarmonyOS Sans SC",
"DejaVu Sans", "Noto Sans", "Noto Sans CJK SC", "Liberation Sans",
]
}
///|
fn skia_serif_families() -> Array[String] {
[
"Times New Roman", "Georgia", "DejaVu Serif", "Noto Serif", "Liberation Serif",
]
}
///|
fn skia_monospace_families() -> Array[String] {
[
"Menlo", "Consolas", "Courier New", "DejaVu Sans Mono", "Noto Sans Mono", "Liberation Mono",
]
}
///|
fn skia_emoji_families() -> Array[String] {
[
"Apple Color Emoji", "Segoe UI Emoji", "HMOS Color Emoji", "HarmonyOS Color Emoji",
"Noto Color Emoji",
]
}
///|
fn skia_fangsong_families() -> Array[String] {
["STFangsong", "FangSong", "FangSong SC", "SimSun"]
}
///|
fn skia_font_slant(style : @core.FontStyle) -> Int {
match style {
Normal => 0
Italic => 1
}
}