mirror of
https://github.com/penpot/penpot.git
synced 2026-11-06 10:00:38 +00:00
Several CJK passes had a copy per writing direction. Merge them without changing their results: - allocate_bounded_expansion and bounded_expansion divide justify and distribute slack for both axes. Each axis still collects its own gaps; the horizontal one checks no-break controls over the source range and the vertical one at cell edges, as before. - PolicyBoundaries holds the break and emergency rules at each grapheme of a paragraph. The horizontal overlong-word and atomic-overflow passes and the vertical pairwise column planner read it. relax_overlong_words moves next to retain_atomic_overflow and shares annotation_ranges. - TextLayoutPolicy::pair_spacing_em gives the compression and script gap that both axes apply with their own caps. - cjk_chars keeps the Han, kana, hangul and punctuation ranges. Ranges that differ on purpose stay separate there, with a note on why. - OverhangClaims tracks the ruby overhang an earlier reading takes. - horizontal_zhuyin_glyphs places Zhuyin for position data and painting. - paint_horizontal_annotations paints warichu, emphasis and ruby; the emoji overlay keeps its place between the marks and the ruby. AI-assisted-by: claude-opus-5-5
509 lines
17 KiB
Rust
509 lines
17 KiB
Rust
use skia_safe::{self as skia, textlayout, Font, FontMgr};
|
|
use std::collections::{BTreeSet, HashMap, HashSet};
|
|
|
|
use crate::error::{Error, Result};
|
|
use crate::shapes::cjk_chars::{is_hangul, is_kana};
|
|
use crate::shapes::text_policy::LanguageTag;
|
|
use crate::shapes::{FontFamily, FontStyle};
|
|
use crate::uuid::Uuid;
|
|
|
|
pub static DEFAULT_EMOJI_FONT: &str = "noto-color-emoji";
|
|
|
|
const DEFAULT_FONT_BYTES: &[u8] = include_bytes!("../fonts/sourcesanspro-regular.ttf");
|
|
const UI_FONT_BYTES: &[u8] = include_bytes!("../fonts/WorkSans-Numeric.ttf");
|
|
|
|
pub fn default_font() -> String {
|
|
let family = FontFamily::new(default_font_uuid(), 400, FontStyle::Normal);
|
|
format!("{}", family)
|
|
}
|
|
|
|
fn default_font_uuid() -> Uuid {
|
|
Uuid::nil()
|
|
}
|
|
|
|
/// CJK region whose glyph forms a fallback face draws, read from its family
|
|
/// name (`Noto Sans JP`, `Noto Sans TC`, ...). The declaration order is the
|
|
/// fallback order when no language prefers a face.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub enum CjkFace {
|
|
SimplifiedChinese,
|
|
TraditionalChinese,
|
|
HongKongChinese,
|
|
Japanese,
|
|
Korean,
|
|
}
|
|
|
|
impl CjkFace {
|
|
fn from_family_name(name: &str) -> Option<Self> {
|
|
match name.rsplit(' ').next()? {
|
|
"SC" => Some(Self::SimplifiedChinese),
|
|
"TC" => Some(Self::TraditionalChinese),
|
|
"HK" => Some(Self::HongKongChinese),
|
|
"JP" => Some(Self::Japanese),
|
|
"KR" => Some(Self::Korean),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Faces drawing Han and CJK punctuation in the forms `language` (a BCP 47
|
|
/// tag) expects, best first. Without a CJK language, kana or hangul in
|
|
/// `text` decide.
|
|
fn preferred(language: &str, text: &str) -> &'static [Self] {
|
|
let tag = LanguageTag::parse(language);
|
|
if tag.is("ja") {
|
|
&[Self::Japanese]
|
|
} else if tag.is("ko") {
|
|
&[Self::Korean]
|
|
} else if tag.is("zh") {
|
|
if tag.has_region(&["HK", "MO"]) {
|
|
&[Self::HongKongChinese, Self::TraditionalChinese]
|
|
} else if tag.has_region(&["TW"]) || tag.has_script("Hant") {
|
|
&[Self::TraditionalChinese, Self::HongKongChinese]
|
|
} else {
|
|
&[Self::SimplifiedChinese]
|
|
}
|
|
} else if text.chars().any(is_kana) {
|
|
&[Self::Japanese]
|
|
} else if text.chars().any(is_hangul) {
|
|
&[Self::Korean]
|
|
} else {
|
|
&[]
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Families that take part in character fallback, with the CJK region of
|
|
/// those that draw Han.
|
|
#[derive(Debug, Default)]
|
|
pub struct FallbackFonts {
|
|
families: BTreeSet<String>,
|
|
cjk_faces: HashMap<String, CjkFace>,
|
|
}
|
|
|
|
impl FallbackFonts {
|
|
/// Fallback families without CJK regions, in name order.
|
|
#[cfg(test)]
|
|
pub fn from_families(families: impl IntoIterator<Item = String>) -> Self {
|
|
Self {
|
|
families: families.into_iter().collect(),
|
|
cjk_faces: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
fn insert(&mut self, alias: String, cjk_face: Option<CjkFace>) {
|
|
if let Some(cjk_face) = cjk_face {
|
|
self.cjk_faces.insert(alias.clone(), cjk_face);
|
|
}
|
|
self.families.insert(alias);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn contains(&self, alias: &str) -> bool {
|
|
self.families.contains(alias)
|
|
}
|
|
|
|
/// Fallback families for `text` in `language`: the CJK faces the language
|
|
/// prefers, then the non-CJK faces, then the other CJK faces. CJK faces
|
|
/// share Han characters with different glyph forms, so the first one
|
|
/// decides how they look.
|
|
pub fn for_text(&self, language: &str, text: &str) -> Vec<String> {
|
|
let preferred = CjkFace::preferred(language, text);
|
|
let mut families: Vec<&String> = self.families.iter().collect();
|
|
families.sort_by_key(|alias| match self.cjk_faces.get(*alias) {
|
|
Some(face) => match preferred.iter().position(|p| p == face) {
|
|
Some(rank) => (0, rank),
|
|
None => (2, *face as usize),
|
|
},
|
|
None => (1, 0),
|
|
});
|
|
families.into_iter().cloned().collect()
|
|
}
|
|
}
|
|
|
|
pub struct FontStore {
|
|
font_mgr: FontMgr,
|
|
font_provider: textlayout::TypefaceFontProvider,
|
|
font_collection: textlayout::FontCollection,
|
|
debug_font: Font,
|
|
ui_font: Font,
|
|
fallback_fonts: FallbackFonts,
|
|
/// CJK region of each registered family that draws Han.
|
|
cjk_faces: HashMap<String, CjkFace>,
|
|
/// Source URL registered when the font was fetched (SVG export references
|
|
/// this in `@font-face` rules).
|
|
source_urls: HashMap<String, String>,
|
|
}
|
|
|
|
impl FontStore {
|
|
pub fn try_new() -> Result<Self> {
|
|
// custom_empty: FreeType-backed manager with no system scan. FontMgr::new()
|
|
// calls SkFontMgr_NewSystem, which on wasm tries to opendir font paths and
|
|
// aborts when Emscripten is built with -sFILESYSTEM=0 (exporter + browser).
|
|
let font_mgr = FontMgr::custom_empty().ok_or(Error::CriticalError(
|
|
"Failed to create empty FontMgr".to_string(),
|
|
))?;
|
|
let font_provider = load_default_provider(&font_mgr);
|
|
let mut font_collection = skia::textlayout::FontCollection::new();
|
|
font_collection.set_default_font_manager(FontMgr::from(font_provider.clone()), None);
|
|
|
|
let debug_typeface = font_provider
|
|
.match_family_style(default_font().as_str(), skia::FontStyle::default())
|
|
.ok_or(Error::CriticalError(
|
|
"Failed to match default font".to_string(),
|
|
))?;
|
|
|
|
let debug_font = skia::Font::new(debug_typeface, 12.0);
|
|
|
|
let ui_typeface = font_mgr
|
|
.new_from_data(skia::Data::new_copy(UI_FONT_BYTES), None)
|
|
.ok_or(Error::CriticalError("Failed to load UI font".to_string()))?;
|
|
let ui_font = skia::Font::new(ui_typeface, 12.0);
|
|
|
|
Ok(Self {
|
|
font_mgr,
|
|
font_provider,
|
|
font_collection,
|
|
debug_font,
|
|
ui_font,
|
|
fallback_fonts: FallbackFonts::default(),
|
|
cjk_faces: HashMap::new(),
|
|
source_urls: HashMap::new(),
|
|
})
|
|
}
|
|
|
|
pub fn set_scale_debug_font(&mut self, dpr: f32) {
|
|
let debug_font = skia::Font::new(self.debug_font.typeface(), 12.0 * dpr);
|
|
self.debug_font = debug_font;
|
|
}
|
|
|
|
pub fn font_provider(&self) -> &textlayout::TypefaceFontProvider {
|
|
&self.font_provider
|
|
}
|
|
|
|
pub fn font_collection(&self) -> &textlayout::FontCollection {
|
|
&self.font_collection
|
|
}
|
|
|
|
pub fn debug_font(&self) -> &Font {
|
|
&self.debug_font
|
|
}
|
|
|
|
pub fn ui_font(&self) -> &Font {
|
|
&self.ui_font
|
|
}
|
|
|
|
pub fn add(
|
|
&mut self,
|
|
family: FontFamily,
|
|
font_data: &[u8],
|
|
is_emoji: bool,
|
|
is_fallback: bool,
|
|
) -> Result<()> {
|
|
let alias = format!("{}", family);
|
|
if self.has_family(&family, is_emoji) {
|
|
if is_fallback {
|
|
self.insert_fallback(alias);
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
let typeface = self
|
|
.font_mgr
|
|
.new_from_data(skia::Data::new_copy(font_data), None)
|
|
.ok_or(Error::CriticalError(
|
|
"Failed to create typeface".to_string(),
|
|
))?;
|
|
|
|
let font_name = if is_emoji {
|
|
DEFAULT_EMOJI_FONT
|
|
} else {
|
|
alias.as_str()
|
|
};
|
|
|
|
if let Some(cjk_face) = CjkFace::from_family_name(&typeface.family_name()) {
|
|
self.cjk_faces.insert(alias.clone(), cjk_face);
|
|
}
|
|
self.font_provider.register_typeface(typeface, font_name);
|
|
self.font_collection.clear_caches();
|
|
|
|
if is_fallback {
|
|
self.insert_fallback(alias);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn insert_fallback(&mut self, alias: String) {
|
|
let cjk_face = self.cjk_faces.get(&alias).copied();
|
|
self.fallback_fonts.insert(alias, cjk_face);
|
|
}
|
|
|
|
/// Upgrade an already-uploaded family to take part in character fallback.
|
|
/// Font bytes are cached apart from a face's role, so a family may arrive
|
|
/// as a document font and later be requested as a fallback.
|
|
pub fn mark_as_fallback(&mut self, family: &FontFamily) {
|
|
if self.has_family(family, false) {
|
|
self.insert_fallback(format!("{}", family));
|
|
}
|
|
}
|
|
|
|
pub fn has_family(&self, family: &FontFamily, is_emoji: bool) -> bool {
|
|
let alias = format!("{}", family);
|
|
let font_name = if is_emoji {
|
|
DEFAULT_EMOJI_FONT
|
|
} else {
|
|
alias.as_str()
|
|
};
|
|
self.font_provider.family_names().any(|x| x == font_name)
|
|
}
|
|
|
|
pub fn get_fallback(&self) -> &FallbackFonts {
|
|
&self.fallback_fonts
|
|
}
|
|
|
|
pub fn set_source_url(&mut self, alias: &str, url: String) {
|
|
if !url.is_empty() {
|
|
self.source_urls.insert(alias.to_string(), url);
|
|
}
|
|
}
|
|
|
|
pub fn source_url(&self, alias: &str) -> Option<&str> {
|
|
self.source_urls.get(alias).map(String::as_str)
|
|
}
|
|
|
|
/// Builds `@font-face` CSS rules for the given registered aliases.
|
|
///
|
|
/// Each rule references the source URL registered for the alias at load
|
|
/// time. Aliases without a registered URL are omitted.
|
|
pub fn font_face_css_for_aliases(&self, aliases: &HashSet<String>) -> String {
|
|
let mut seen: HashSet<String> = HashSet::new();
|
|
let mut css = String::new();
|
|
|
|
for alias in aliases {
|
|
let Some(typeface) = self
|
|
.font_provider
|
|
.match_family_style(alias, skia::FontStyle::default())
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
let family = typeface.family_name();
|
|
let style = typeface.font_style();
|
|
|
|
// Skia's SVG backend derives `<text>` font descriptors from the
|
|
// typeface's own `SkFontStyle` using a quirky bucketed table (see
|
|
// `skia_svg_font_weight`). We must mirror it exactly here so each
|
|
// `@font-face` pairs with the `<text>` elements that reference it;
|
|
// otherwise, when several weights of the same family coexist, the
|
|
// browser cannot match the weight and silently falls back to 400.
|
|
let weight = skia_svg_font_weight(*style.weight());
|
|
let slant = match style.slant() {
|
|
skia::font_style::Slant::Italic => "italic",
|
|
skia::font_style::Slant::Oblique => "oblique",
|
|
_ => "normal",
|
|
};
|
|
let stretch = skia_svg_font_stretch(*style.width());
|
|
|
|
let dedup_key = format!("{family}|{weight}|{slant}|{stretch:?}");
|
|
if !seen.insert(dedup_key) {
|
|
continue;
|
|
}
|
|
|
|
let stretch_decl = stretch
|
|
.map(|s| format!("font-stretch:{s};"))
|
|
.unwrap_or_default();
|
|
|
|
let Some(url) = self.source_url(alias) else {
|
|
continue;
|
|
};
|
|
let src = font_face_src_from_url(url);
|
|
|
|
css.push_str(&format!(
|
|
"@font-face{{font-family:\"{family}\";font-style:{slant};font-weight:{weight};{stretch_decl}src:{src};}}",
|
|
));
|
|
}
|
|
|
|
css
|
|
}
|
|
}
|
|
|
|
fn font_face_src_from_url(url: &str) -> String {
|
|
let format = font_format_from_url(url);
|
|
format!("url(\"{}\") format(\"{format}\")", css_escape_url(url))
|
|
}
|
|
|
|
fn font_format_from_url(url: &str) -> &'static str {
|
|
let path = url
|
|
.split('#')
|
|
.next()
|
|
.unwrap_or(url)
|
|
.split('?')
|
|
.next()
|
|
.unwrap_or(url);
|
|
if path.ends_with(".woff2") {
|
|
"woff2"
|
|
} else if path.ends_with(".woff") {
|
|
"woff"
|
|
} else if path.ends_with(".otf") {
|
|
"opentype"
|
|
} else {
|
|
"truetype"
|
|
}
|
|
}
|
|
|
|
fn css_escape_url(url: &str) -> String {
|
|
url.replace('\\', "\\\\").replace('"', "\\\"")
|
|
}
|
|
|
|
/// Reproduces the `font-weight` string that `SkSVGDevice::addTextAttributes`
|
|
/// writes on `<text>` elements for a given typeface weight.
|
|
fn skia_svg_font_weight(weight: i32) -> &'static str {
|
|
// Skia's table is ["100","200","300","normal","400","500","600","bold",
|
|
// "800","900"]; we substitute "400" for the omitted-normal bucket so the
|
|
// descriptor still resolves to weight 400.
|
|
const WEIGHTS: [&str; 10] = [
|
|
"100", "200", "300", "400", "400", "500", "600", "bold", "800", "900",
|
|
];
|
|
let index = ((weight.clamp(100, 900) - 50) / 100) as usize;
|
|
WEIGHTS[index]
|
|
}
|
|
|
|
/// Reproduces the `font-stretch` value `SkSVGDevice` writes for a typeface
|
|
/// width, returning `None` for the normal width (which Skia omits).
|
|
fn skia_svg_font_stretch(width: i32) -> Option<&'static str> {
|
|
const STRETCHES: [&str; 9] = [
|
|
"ultra-condensed",
|
|
"extra-condensed",
|
|
"condensed",
|
|
"semi-condensed",
|
|
"normal",
|
|
"semi-expanded",
|
|
"expanded",
|
|
"extra-expanded",
|
|
"ultra-expanded",
|
|
];
|
|
let index = width - 1;
|
|
if index == 4 {
|
|
return None;
|
|
}
|
|
STRETCHES.get(usize::try_from(index).ok()?).copied()
|
|
}
|
|
|
|
fn load_default_provider(font_mgr: &FontMgr) -> skia::textlayout::TypefaceFontProvider {
|
|
let mut font_provider = skia::textlayout::TypefaceFontProvider::new();
|
|
|
|
let family = FontFamily::new(default_font_uuid(), 400, FontStyle::Normal);
|
|
let font = font_mgr
|
|
.new_from_data(skia::Data::new_copy(DEFAULT_FONT_BYTES), None)
|
|
.expect("Failed to load font");
|
|
font_provider.register_typeface(font, family.alias().as_str());
|
|
|
|
font_provider
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::shapes::{FontFamily, FontStyle};
|
|
use crate::uuid::Uuid;
|
|
|
|
#[test]
|
|
fn font_face_css_uses_registered_url() {
|
|
let mut store = FontStore::try_new().expect("font store");
|
|
let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal);
|
|
let alias = family.alias();
|
|
store.set_source_url(&alias, "https://example.com/fonts/source.ttf".to_string());
|
|
|
|
let mut aliases = HashSet::new();
|
|
aliases.insert(alias);
|
|
let css = store.font_face_css_for_aliases(&aliases);
|
|
|
|
assert!(css.contains("url(\"https://example.com/fonts/source.ttf\")"));
|
|
assert!(css.contains("format(\"truetype\")"));
|
|
assert!(!css.contains("base64,"));
|
|
}
|
|
|
|
#[test]
|
|
fn font_face_css_skips_without_registered_url() {
|
|
let store = FontStore::try_new().expect("font store");
|
|
let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal);
|
|
let mut aliases = HashSet::new();
|
|
aliases.insert(family.alias());
|
|
let css = store.font_face_css_for_aliases(&aliases);
|
|
|
|
assert!(css.is_empty());
|
|
}
|
|
|
|
const TEST_FONT: &[u8] = include_bytes!("../fonts/sourcesanspro-regular.ttf");
|
|
|
|
fn cjk_fallbacks() -> FallbackFonts {
|
|
let mut fallbacks = FallbackFonts::default();
|
|
fallbacks.insert("arabic".to_string(), None);
|
|
fallbacks.insert("jp".to_string(), Some(CjkFace::Japanese));
|
|
fallbacks.insert("kr".to_string(), Some(CjkFace::Korean));
|
|
fallbacks.insert("sc".to_string(), Some(CjkFace::SimplifiedChinese));
|
|
fallbacks.insert("tc".to_string(), Some(CjkFace::TraditionalChinese));
|
|
fallbacks.insert("hk".to_string(), Some(CjkFace::HongKongChinese));
|
|
fallbacks
|
|
}
|
|
|
|
#[test]
|
|
fn cjk_face_comes_from_the_family_region_suffix() {
|
|
assert_eq!(
|
|
CjkFace::from_family_name("Noto Sans JP"),
|
|
Some(CjkFace::Japanese)
|
|
);
|
|
assert_eq!(
|
|
CjkFace::from_family_name("Noto Sans HK"),
|
|
Some(CjkFace::HongKongChinese)
|
|
);
|
|
assert_eq!(CjkFace::from_family_name("Noto Sans Arabic"), None);
|
|
assert_eq!(CjkFace::from_family_name(""), None);
|
|
}
|
|
|
|
#[test]
|
|
fn paragraph_language_puts_its_cjk_face_first() {
|
|
let fallbacks = cjk_fallbacks();
|
|
assert_eq!(fallbacks.for_text("ja", "東京")[0], "jp");
|
|
assert_eq!(fallbacks.for_text("ko", "漢字")[0], "kr");
|
|
assert_eq!(fallbacks.for_text("zh-Hans", "东京")[0], "sc");
|
|
assert_eq!(fallbacks.for_text("zh-Hant-TW", "東京")[..2], ["tc", "hk"]);
|
|
assert_eq!(fallbacks.for_text("zh-Hant-HK", "東京")[..2], ["hk", "tc"]);
|
|
assert_eq!(fallbacks.for_text("zh_hant", "東京")[0], "tc");
|
|
}
|
|
|
|
#[test]
|
|
fn untagged_text_prefers_the_face_of_its_kana_or_hangul() {
|
|
let fallbacks = cjk_fallbacks();
|
|
assert_eq!(fallbacks.for_text("", "東京へ行く")[0], "jp");
|
|
assert_eq!(fallbacks.for_text("en", "서울")[0], "kr");
|
|
}
|
|
|
|
#[test]
|
|
fn fallback_order_is_stable_without_a_preference() {
|
|
let fallbacks = cjk_fallbacks();
|
|
assert_eq!(
|
|
fallbacks.for_text("", "東京"),
|
|
["arabic", "sc", "tc", "hk", "jp", "kr"]
|
|
);
|
|
// Non-CJK faces follow the preferred face, other CJK faces come last.
|
|
assert_eq!(
|
|
fallbacks.for_text("ja", "東京"),
|
|
["jp", "arabic", "sc", "tc", "hk", "kr"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn uploaded_family_can_be_upgraded_to_fallback() {
|
|
let mut store = FontStore::try_new().unwrap();
|
|
let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal);
|
|
let alias = format!("{}", family);
|
|
|
|
store.add(family, TEST_FONT, false, false).unwrap();
|
|
assert!(!store.get_fallback().contains(&alias));
|
|
|
|
store.mark_as_fallback(&family);
|
|
assert!(store.get_fallback().contains(&alias));
|
|
}
|
|
}
|