Improve modern layout and viewport rendering
This commit is contained in:
1 parent
aff0172987
commit
10b8492116
22 files changed
+5978
-450
No files matched your search
@@ -14,7 +14,12 @@
|
||||
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use cosmic_text::{Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, Style, SwashCache, Weight, Wrap};
|
||||
use cosmic_text::{
|
||||
Align, Attrs, Buffer, CacheKey, CacheKeyFlags, Color, Family, FontSystem, Metrics, Shaping,
|
||||
Style, SwashCache, SwashImage, Weight, Wrap,
|
||||
};
|
||||
use swash::scale::{image::Content as SwashContent, Render, ScaleContext, Source, StrikeWith};
|
||||
use swash::zeno::{Angle, Format, Transform, Vector};
|
||||
|
||||
use obscura_dom::tree::{DomTree, NodeId};
|
||||
|
||||
@@ -90,7 +95,15 @@ struct LoadedFace {
|
||||
max_weight: u16,
|
||||
shape_weight: u16,
|
||||
italic: bool,
|
||||
variable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ResolvedFont {
|
||||
family: Arc<str>,
|
||||
shape_weight: u16,
|
||||
/// Authored `wght` coordinate for a ranged @font-face. Cosmic Text 0.12
|
||||
/// cannot carry this into shaping, so it is kept separately for raster.
|
||||
variation_weight: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -106,7 +119,7 @@ fn resolve_loaded_font(
|
||||
requested_weight: u16,
|
||||
requested_italic: bool,
|
||||
loaded: &HashMap<String, LoadedFamily>,
|
||||
) -> (Arc<str>, u16) {
|
||||
) -> ResolvedFont {
|
||||
if let Some(stack) = fam {
|
||||
for token in stack.split(',') {
|
||||
let name = token
|
||||
@@ -124,15 +137,25 @@ fn resolve_loaded_font(
|
||||
} else {
|
||||
exact_style
|
||||
};
|
||||
if let Some(face) = candidates.iter().copied().find(|face| {
|
||||
(face.min_weight..=face.max_weight).contains(&requested_weight)
|
||||
}) {
|
||||
let weight = if face.variable {
|
||||
requested_weight
|
||||
} else {
|
||||
face.shape_weight
|
||||
if let Some(face) = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|face| (face.min_weight..=face.max_weight).contains(&requested_weight))
|
||||
{
|
||||
// cosmic-text 0.12 only accepts the named family when the
|
||||
// shaping weight exactly matches fontdb's weight for that
|
||||
// file. A variable face commonly advertises `100 900` in
|
||||
// CSS while fontdb records its default instance as 400.
|
||||
// Passing the authored weight makes cosmic-text reject the
|
||||
// right family and fall through to an unrelated exact-
|
||||
// weight face. Select the file at its database weight;
|
||||
// variable-axis shaping is not exposed by this version.
|
||||
return ResolvedFont {
|
||||
family: Arc::clone(&face.name),
|
||||
shape_weight: face.shape_weight,
|
||||
variation_weight: (face.min_weight != face.max_weight)
|
||||
.then_some(requested_weight),
|
||||
};
|
||||
return (Arc::clone(&face.name), weight);
|
||||
}
|
||||
let available: Vec<_> =
|
||||
candidates.iter().map(|face| face.min_weight).collect();
|
||||
@@ -141,14 +164,22 @@ fn resolve_loaded_font(
|
||||
.into_iter()
|
||||
.find(|face| face.min_weight == matched)
|
||||
{
|
||||
return (Arc::clone(&face.name), face.shape_weight);
|
||||
return ResolvedFont {
|
||||
family: Arc::clone(&face.name),
|
||||
shape_weight: face.shape_weight,
|
||||
variation_weight: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let fallback = resolve_font_family(fam);
|
||||
let weights: &[u16] = &[400, 700];
|
||||
(Arc::from(fallback), match_font_weight(requested_weight, weights))
|
||||
ResolvedFont {
|
||||
family: Arc::from(fallback),
|
||||
shape_weight: match_font_weight(requested_weight, weights),
|
||||
variation_weight: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// CSS Fonts' asymmetric missing-weight search. In particular, 600 selects
|
||||
@@ -191,28 +222,6 @@ fn match_font_weight(requested: u16, available: &[u16]) -> u16 {
|
||||
}
|
||||
}
|
||||
|
||||
fn face_is_variable(data: &[u8], face_index: u32) -> bool {
|
||||
let base = if data.get(..4) == Some(b"ttcf") {
|
||||
let offset = 12usize.saturating_add(face_index as usize * 4);
|
||||
data.get(offset..offset + 4)
|
||||
.and_then(|bytes| bytes.try_into().ok())
|
||||
.map(u32::from_be_bytes)
|
||||
.unwrap_or(0) as usize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let Some(count) = data
|
||||
.get(base + 4..base + 6)
|
||||
.and_then(|bytes| bytes.try_into().ok())
|
||||
.map(u16::from_be_bytes)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
(0..count as usize).any(|index| {
|
||||
let start = base + 12 + index * 16;
|
||||
data.get(start..start + 4) == Some(b"fvar")
|
||||
})
|
||||
}
|
||||
/// Resolve `line-height: normal` from the selected face's horizontal header.
|
||||
///
|
||||
/// Chromium's FreeType-backed Linux path grid-fits the ascent, descent, and
|
||||
@@ -258,6 +267,20 @@ pub(crate) fn used_line_height(style: &LayoutStyle) -> f32 {
|
||||
/// into an [`InlineItem`]'s clip-text fills.
|
||||
const META_UNDERLINE: usize = 1;
|
||||
const META_FILL_SHIFT: usize = 1;
|
||||
const META_VARIATION_BITS: usize = 10;
|
||||
const META_VARIATION_SHIFT: usize = usize::BITS as usize - META_VARIATION_BITS;
|
||||
const META_VARIATION_MASK: usize =
|
||||
((1usize << META_VARIATION_BITS) - 1) << META_VARIATION_SHIFT;
|
||||
const META_FILL_MASK: usize = ((1usize << META_VARIATION_SHIFT) - 1) & !META_UNDERLINE;
|
||||
|
||||
fn metadata_fill(metadata: usize) -> Option<usize> {
|
||||
((metadata & META_FILL_MASK) >> META_FILL_SHIFT).checked_sub(1)
|
||||
}
|
||||
|
||||
fn metadata_variation_weight(metadata: usize) -> Option<u16> {
|
||||
let weight = ((metadata & META_VARIATION_MASK) >> META_VARIATION_SHIFT) as u16;
|
||||
(weight != 0).then_some(weight)
|
||||
}
|
||||
|
||||
type ClipTextFill = (f32, Vec<([u8; 4], Option<f32>)>);
|
||||
|
||||
@@ -286,10 +309,106 @@ pub struct TextEngine {
|
||||
font_system: FontSystem,
|
||||
loaded_families: HashMap<String, LoadedFamily>,
|
||||
swash: SwashCache,
|
||||
variable_swash: VariableSwashCache,
|
||||
items: Vec<InlineItem>,
|
||||
replaced: Vec<ReplacedItem>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
struct VariableCacheKey {
|
||||
glyph: CacheKey,
|
||||
weight: u16,
|
||||
}
|
||||
|
||||
/// Swash's ordinary cache key intentionally has no variation coordinates.
|
||||
/// Keep variable instances in a separate cache so a 400 glyph can never be
|
||||
/// reused for 700 and repeated paint remains O(1) after the first raster.
|
||||
struct VariableSwashCache {
|
||||
context: ScaleContext,
|
||||
images: HashMap<VariableCacheKey, Option<SwashImage>>,
|
||||
}
|
||||
|
||||
impl VariableSwashCache {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
context: ScaleContext::new(),
|
||||
images: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_pixels<F: FnMut(i32, i32, Color)>(
|
||||
&mut self,
|
||||
font_system: &mut FontSystem,
|
||||
cache_key: CacheKey,
|
||||
weight: u16,
|
||||
base: Color,
|
||||
mut f: F,
|
||||
) {
|
||||
let key = VariableCacheKey {
|
||||
glyph: cache_key,
|
||||
weight,
|
||||
};
|
||||
let image = self.images.entry(key).or_insert_with(|| {
|
||||
let font = font_system.get_font(cache_key.font_id)?;
|
||||
let mut scaler = self
|
||||
.context
|
||||
.builder(font.as_swash())
|
||||
.size(f32::from_bits(cache_key.font_size_bits))
|
||||
.hint(true)
|
||||
.variations([("wght", weight as f32)])
|
||||
.build();
|
||||
let offset = Vector::new(cache_key.x_bin.as_float(), cache_key.y_bin.as_float());
|
||||
Render::new(&[
|
||||
Source::ColorOutline(0),
|
||||
Source::ColorBitmap(StrikeWith::BestFit),
|
||||
Source::Outline,
|
||||
])
|
||||
.format(Format::Alpha)
|
||||
.offset(offset)
|
||||
.transform(
|
||||
cache_key
|
||||
.flags
|
||||
.contains(CacheKeyFlags::FAKE_ITALIC)
|
||||
.then(|| {
|
||||
Transform::skew(
|
||||
Angle::from_degrees(14.0),
|
||||
Angle::from_degrees(0.0),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.render(&mut scaler, cache_key.glyph_id)
|
||||
});
|
||||
let Some(image) = image else { return };
|
||||
let left = image.placement.left;
|
||||
let top = -image.placement.top;
|
||||
match image.content {
|
||||
SwashContent::Mask => {
|
||||
for (index, alpha) in image.data.iter().copied().enumerate() {
|
||||
let x = index as i32 % image.placement.width as i32;
|
||||
let y = index as i32 / image.placement.width as i32;
|
||||
f(
|
||||
left + x,
|
||||
top + y,
|
||||
Color(((alpha as u32) << 24) | base.0 & 0x00FF_FFFF),
|
||||
);
|
||||
}
|
||||
}
|
||||
SwashContent::Color => {
|
||||
for (index, rgba) in image.data.chunks_exact(4).enumerate() {
|
||||
let x = index as i32 % image.placement.width as i32;
|
||||
let y = index as i32 / image.placement.width as i32;
|
||||
f(
|
||||
left + x,
|
||||
top + y,
|
||||
Color::rgba(rgba[0], rgba[1], rgba[2], rgba[3]),
|
||||
);
|
||||
}
|
||||
}
|
||||
SwashContent::SubpixelMask => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const REPLACED_CONTEXT_BIT: usize = 1usize << (usize::BITS - 1);
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -407,9 +526,6 @@ impl TextEngine {
|
||||
let shape_weight = face.weight.0;
|
||||
let italic = declared_italic
|
||||
.unwrap_or(!matches!(face.style, cosmic_text::fontdb::Style::Normal));
|
||||
let variable = db
|
||||
.with_face_data(id, face_is_variable)
|
||||
.unwrap_or(false);
|
||||
let weight = declared_weight.unwrap_or((shape_weight, shape_weight));
|
||||
let declared_names: Vec<String> = declared_family
|
||||
.map(|name| vec![name])
|
||||
@@ -424,7 +540,6 @@ impl TextEngine {
|
||||
max_weight: weight.1,
|
||||
shape_weight,
|
||||
italic,
|
||||
variable,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -434,6 +549,7 @@ impl TextEngine {
|
||||
font_system,
|
||||
loaded_families,
|
||||
swash: SwashCache::new(),
|
||||
variable_swash: VariableSwashCache::new(),
|
||||
items: Vec::new(),
|
||||
replaced: Vec::new(),
|
||||
}
|
||||
@@ -458,18 +574,13 @@ impl TextEngine {
|
||||
}
|
||||
let base = styles.get(&id)?;
|
||||
let mut collector = Collector { last_was_space: true, clip_fills: Vec::new() };
|
||||
let (family, weight) = resolve_loaded_font(
|
||||
let font = resolve_loaded_font(
|
||||
base.font_family.as_deref(),
|
||||
crate::style::used_font_weight(base),
|
||||
base.font_style_italic.unwrap_or(false),
|
||||
&self.loaded_families,
|
||||
);
|
||||
let ctx = base_span_ctx(
|
||||
base,
|
||||
family,
|
||||
weight,
|
||||
&mut collector,
|
||||
);
|
||||
let ctx = base_span_ctx(base, font, &mut collector);
|
||||
let mut spans: Vec<(String, SpanAttrs)> = Vec::new();
|
||||
collect_spans(
|
||||
tree,
|
||||
@@ -508,18 +619,13 @@ impl TextEngine {
|
||||
}
|
||||
let base = styles.get(&parent)?;
|
||||
let mut collector = Collector { last_was_space: true, clip_fills: Vec::new() };
|
||||
let (family, weight) = resolve_loaded_font(
|
||||
let font = resolve_loaded_font(
|
||||
base.font_family.as_deref(),
|
||||
crate::style::used_font_weight(base),
|
||||
base.font_style_italic.unwrap_or(false),
|
||||
&self.loaded_families,
|
||||
);
|
||||
let ctx = base_span_ctx(
|
||||
base,
|
||||
family,
|
||||
weight,
|
||||
&mut collector,
|
||||
);
|
||||
let ctx = base_span_ctx(base, font, &mut collector);
|
||||
let mut spans: Vec<(String, SpanAttrs)> = Vec::new();
|
||||
for &cid in run {
|
||||
collect_node_spans(
|
||||
@@ -551,17 +657,18 @@ impl TextEngine {
|
||||
last_was_space: true,
|
||||
clip_fills: Vec::new(),
|
||||
};
|
||||
let (family, weight) = resolve_loaded_font(
|
||||
let font = resolve_loaded_font(
|
||||
style.font_family.as_deref(),
|
||||
crate::style::used_font_weight(style),
|
||||
style.font_style_italic.unwrap_or(false),
|
||||
&self.loaded_families,
|
||||
);
|
||||
let context = base_span_ctx(style, family, weight, &mut collector);
|
||||
let context = base_span_ctx(style, font, &mut collector);
|
||||
let attrs = SpanAttrs {
|
||||
font_size: context.font_size,
|
||||
line_height: context.line_height,
|
||||
weight: context.weight,
|
||||
variation_weight: context.variation_weight,
|
||||
italic: context.italic,
|
||||
underline: context.underline,
|
||||
color: context.color,
|
||||
@@ -735,6 +842,7 @@ struct SpanAttrs {
|
||||
font_size: f32,
|
||||
line_height: f32,
|
||||
weight: u16,
|
||||
variation_weight: Option<u16>,
|
||||
italic: bool,
|
||||
underline: bool,
|
||||
color: [u8; 4],
|
||||
@@ -759,9 +867,14 @@ impl SpanAttrs {
|
||||
// reaches paint; the real gradient is selected through metadata.
|
||||
let color = if self.clip_fill.is_some() { [255, 255, 255, 255] } else { self.color };
|
||||
a = a.color(Color::rgba(color[0], color[1], color[2], color[3]));
|
||||
// Underline and the optional fill index share the per-glyph metadata.
|
||||
// Underline, the optional fill index, and a variable `wght` coordinate
|
||||
// share the per-glyph metadata.
|
||||
let fill = self.clip_fill.map_or(0, |index| (index + 1) << META_FILL_SHIFT);
|
||||
a = a.metadata(fill | usize::from(self.underline));
|
||||
debug_assert_eq!(fill & !META_FILL_MASK, 0);
|
||||
let variation = self
|
||||
.variation_weight
|
||||
.map_or(0, |weight| usize::from(weight.clamp(1, 1000)) << META_VARIATION_SHIFT);
|
||||
a = a.metadata(fill | variation | usize::from(self.underline));
|
||||
a
|
||||
}
|
||||
}
|
||||
@@ -773,6 +886,7 @@ struct SpanCtx {
|
||||
line_height: f32,
|
||||
color: [u8; 4],
|
||||
weight: u16,
|
||||
variation_weight: Option<u16>,
|
||||
italic: bool,
|
||||
underline: bool,
|
||||
transform: TextTransform,
|
||||
@@ -800,8 +914,7 @@ struct Collector {
|
||||
/// transparent (and invisible), unchanged.
|
||||
fn base_span_ctx(
|
||||
base: &LayoutStyle,
|
||||
family: Arc<str>,
|
||||
weight: u16,
|
||||
font: ResolvedFont,
|
||||
collector: &mut Collector,
|
||||
) -> SpanCtx {
|
||||
let clip_fill = clip_text_fill(base).map(|fill| {
|
||||
@@ -813,11 +926,12 @@ fn base_span_ctx(
|
||||
font_size: base.font_size.unwrap_or(16.0),
|
||||
line_height: used_line_height(base),
|
||||
color: base.color.unwrap_or([0, 0, 0, 255]),
|
||||
weight,
|
||||
weight: font.shape_weight,
|
||||
variation_weight: font.variation_weight,
|
||||
italic: base.font_style_italic.unwrap_or(false),
|
||||
underline: base.underline.unwrap_or(false),
|
||||
transform: base.text_transform.unwrap_or(TextTransform::None),
|
||||
family,
|
||||
family: font.family,
|
||||
clip_fill,
|
||||
}
|
||||
}
|
||||
@@ -864,6 +978,7 @@ fn collect_node_spans(
|
||||
font_size: ctx.font_size,
|
||||
line_height: ctx.line_height,
|
||||
weight: ctx.weight,
|
||||
variation_weight: ctx.variation_weight,
|
||||
italic: ctx.italic,
|
||||
underline: ctx.underline,
|
||||
color: ctx.color,
|
||||
@@ -883,6 +998,7 @@ fn collect_node_spans(
|
||||
font_size: ctx.font_size,
|
||||
line_height: ctx.line_height,
|
||||
weight: ctx.weight,
|
||||
variation_weight: ctx.variation_weight,
|
||||
italic: ctx.italic,
|
||||
underline: ctx.underline,
|
||||
color: ctx.color,
|
||||
@@ -907,7 +1023,7 @@ fn collect_node_spans(
|
||||
let requested_weight = style
|
||||
.map(crate::style::used_font_weight)
|
||||
.unwrap_or(ctx.weight);
|
||||
let (family, weight) = style
|
||||
let font = style
|
||||
.and_then(|style| style.font_family.as_deref())
|
||||
.map(|family| {
|
||||
resolve_loaded_font(
|
||||
@@ -919,7 +1035,17 @@ fn collect_node_spans(
|
||||
loaded_families,
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| (Arc::clone(&ctx.family), requested_weight));
|
||||
.unwrap_or_else(|| ResolvedFont {
|
||||
family: Arc::clone(&ctx.family),
|
||||
shape_weight: if ctx.variation_weight.is_some() {
|
||||
ctx.weight
|
||||
} else {
|
||||
requested_weight
|
||||
},
|
||||
variation_weight: ctx
|
||||
.variation_weight
|
||||
.map(|_| requested_weight.clamp(1, 1000)),
|
||||
});
|
||||
let child = SpanCtx {
|
||||
font_size: style
|
||||
.and_then(|style| style.font_size)
|
||||
@@ -928,13 +1054,14 @@ fn collect_node_spans(
|
||||
.map(used_line_height)
|
||||
.unwrap_or(ctx.line_height),
|
||||
color,
|
||||
weight,
|
||||
weight: font.shape_weight,
|
||||
variation_weight: font.variation_weight,
|
||||
italic: ctx.italic || style.and_then(|s| s.font_style_italic).unwrap_or(false),
|
||||
// Underline propagates in: an ancestor's underline covers
|
||||
// descendant text; an element only sets its own via CSS.
|
||||
underline: ctx.underline || style.and_then(|s| s.underline).unwrap_or(false),
|
||||
transform: style.and_then(|s| s.text_transform).unwrap_or(ctx.transform),
|
||||
family,
|
||||
family: font.family,
|
||||
clip_fill,
|
||||
};
|
||||
collect_spans(
|
||||
@@ -998,8 +1125,17 @@ fn buffer_size(buffer: &Buffer) -> (f32, f32) {
|
||||
/// plain inline formatting (no atomic inline boxes, no block children, no
|
||||
/// inline element carrying its own box)? Such a container collapses cleanly
|
||||
/// to one shaped buffer; anything else keeps the general build path.
|
||||
fn is_pure_text_ifc(tree: &DomTree, id: NodeId, styles: &std::collections::HashMap<NodeId, LayoutStyle>) -> bool {
|
||||
let Some(style) = styles.get(&id) else { return false };
|
||||
fn is_pure_text_ifc(
|
||||
tree: &DomTree,
|
||||
id: NodeId,
|
||||
styles: &std::collections::HashMap<NodeId, LayoutStyle>,
|
||||
) -> bool {
|
||||
let Some(style) = styles.get(&id) else {
|
||||
return false;
|
||||
};
|
||||
if style.before_pseudo.is_some() || style.after_pseudo.is_some() {
|
||||
return false;
|
||||
}
|
||||
// Only containers that lay their children out in normal flow (block, or
|
||||
// the flex-column stand-ins our UA sheet uses for td/th/center). A real
|
||||
// flex/grid row with inline children is rare and better left to taffy.
|
||||
@@ -1080,8 +1216,8 @@ fn inline_child_ok(tree: &DomTree, cid: NodeId, styles: &std::collections::HashM
|
||||
&& style.position.is_none()
|
||||
&& style.float.is_none()
|
||||
&& !style.overflow_hidden
|
||||
&& style.before_content.is_none()
|
||||
&& style.after_content.is_none();
|
||||
&& style.before_pseudo.is_none()
|
||||
&& style.after_pseudo.is_none();
|
||||
if !foldable_inline {
|
||||
return false;
|
||||
}
|
||||
@@ -1177,6 +1313,7 @@ impl TextEngine {
|
||||
let TextEngine {
|
||||
font_system,
|
||||
swash,
|
||||
variable_swash,
|
||||
items,
|
||||
..
|
||||
} = self;
|
||||
@@ -1203,7 +1340,7 @@ impl TextEngine {
|
||||
let mut seg: Option<(f32, f32, f32, [u8; 4])> = None; // x0, x1, font_size, color
|
||||
for g in run.glyphs {
|
||||
let underlined = g.metadata & META_UNDERLINE != 0;
|
||||
if let Some(fill_index) = (g.metadata >> META_FILL_SHIFT).checked_sub(1) {
|
||||
if let Some(fill_index) = metadata_fill(g.metadata) {
|
||||
if let Some(bounds) = fill_bounds.get_mut(fill_index) {
|
||||
let glyph_bounds = (
|
||||
g.x,
|
||||
@@ -1259,12 +1396,8 @@ impl TextEngine {
|
||||
for glyph in run.glyphs {
|
||||
let physical = glyph.physical((0.0, 0.0), 1.0);
|
||||
let glyph_color = glyph.color_opt.unwrap_or(default);
|
||||
let fill_index = (glyph.metadata >> META_FILL_SHIFT).checked_sub(1);
|
||||
swash.with_pixels(
|
||||
font_system,
|
||||
physical.cache_key,
|
||||
glyph_color,
|
||||
|x, y, color| {
|
||||
let fill_index = metadata_fill(glyph.metadata);
|
||||
let mut draw_pixel = |x, y, color: Color| {
|
||||
let a = color.a() as u32;
|
||||
if a == 0 {
|
||||
return;
|
||||
@@ -1311,8 +1444,23 @@ impl TextEngine {
|
||||
let out_b = sb + (dst.blue() as u32 * inv / 255);
|
||||
pixels[idx] = tiny_skia::PremultipliedColorU8::from_rgba(out_r as u8, out_g as u8, out_b as u8, out_a as u8)
|
||||
.unwrap_or(dst);
|
||||
},
|
||||
);
|
||||
};
|
||||
if let Some(weight) = metadata_variation_weight(glyph.metadata) {
|
||||
variable_swash.with_pixels(
|
||||
font_system,
|
||||
physical.cache_key,
|
||||
weight,
|
||||
glyph_color,
|
||||
&mut draw_pixel,
|
||||
);
|
||||
} else {
|
||||
swash.with_pixels(
|
||||
font_system,
|
||||
physical.cache_key,
|
||||
glyph_color,
|
||||
&mut draw_pixel,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1349,6 +1497,7 @@ impl TextEngine {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::Engine as _;
|
||||
|
||||
const RED: [u8; 4] = [255, 0, 0, 255];
|
||||
const BLUE: [u8; 4] = [0, 0, 255, 255];
|
||||
@@ -1383,7 +1532,6 @@ mod tests {
|
||||
max_weight: 400,
|
||||
shape_weight: 400,
|
||||
italic: false,
|
||||
variable: false,
|
||||
},
|
||||
LoadedFace {
|
||||
name: Arc::from("Poppins Medium"),
|
||||
@@ -1391,7 +1539,6 @@ mod tests {
|
||||
max_weight: 500,
|
||||
shape_weight: 500,
|
||||
italic: false,
|
||||
variable: false,
|
||||
},
|
||||
LoadedFace {
|
||||
name: Arc::from("Poppins"),
|
||||
@@ -1399,17 +1546,203 @@ mod tests {
|
||||
max_weight: 700,
|
||||
shape_weight: 700,
|
||||
italic: false,
|
||||
variable: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
let loaded = HashMap::from([("poppins".to_string(), family)]);
|
||||
let medium = resolve_loaded_font(Some("Poppins, sans-serif"), 500, false, &loaded);
|
||||
assert_eq!(medium.0.as_ref(), "Poppins Medium");
|
||||
assert_eq!(medium.1, 500);
|
||||
assert_eq!(medium.family.as_ref(), "Poppins Medium");
|
||||
assert_eq!(medium.shape_weight, 500);
|
||||
assert_eq!(medium.variation_weight, None);
|
||||
let semibold = resolve_loaded_font(Some("Poppins"), 600, false, &loaded);
|
||||
assert_eq!(semibold.0.as_ref(), "Poppins");
|
||||
assert_eq!(semibold.1, 700);
|
||||
assert_eq!(semibold.family.as_ref(), "Poppins");
|
||||
assert_eq!(semibold.shape_weight, 700);
|
||||
assert_eq!(semibold.variation_weight, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranged_face_shapes_at_its_font_database_weight() {
|
||||
let tree = obscura_dom::parse_html("<p id='copy'>Variable family</p>");
|
||||
let copy = tree.get_element_by_id("copy").unwrap();
|
||||
let mut style = LayoutStyle::default();
|
||||
style.display = Display::Block;
|
||||
style.font_family = Some("test variable".to_string());
|
||||
style.font_weight = Some("700".to_string());
|
||||
style.font_size = Some(32.0);
|
||||
let styles = HashMap::from([(copy, style)]);
|
||||
|
||||
let mut engine = TextEngine::new();
|
||||
engine.loaded_families.insert(
|
||||
"test variable".to_string(),
|
||||
LoadedFamily {
|
||||
faces: vec![LoadedFace {
|
||||
name: Arc::from(FAMILY),
|
||||
min_weight: 100,
|
||||
max_weight: 900,
|
||||
shape_weight: 400,
|
||||
italic: false,
|
||||
}],
|
||||
},
|
||||
);
|
||||
|
||||
let item = engine.try_build(&tree, copy, &styles).unwrap();
|
||||
engine.measure(item, None);
|
||||
let font_id = engine.items[item]
|
||||
.buffer
|
||||
.layout_runs()
|
||||
.next()
|
||||
.and_then(|run| run.glyphs.first())
|
||||
.map(|glyph| glyph.font_id)
|
||||
.unwrap();
|
||||
let face = engine.font_system.db().face(font_id).unwrap();
|
||||
assert!(
|
||||
face.families.iter().any(|(name, _)| name == FAMILY),
|
||||
"authored family must not fall through to an unrelated exact-weight face"
|
||||
);
|
||||
assert_eq!(face.weight.0, 400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranged_face_preserves_requested_weight_for_variable_raster() {
|
||||
let loaded = HashMap::from([(
|
||||
"inter".to_string(),
|
||||
LoadedFamily {
|
||||
faces: vec![LoadedFace {
|
||||
name: Arc::from("Inter"),
|
||||
min_weight: 100,
|
||||
max_weight: 900,
|
||||
shape_weight: 400,
|
||||
italic: false,
|
||||
}],
|
||||
},
|
||||
)]);
|
||||
let resolved = resolve_loaded_font(Some("Inter, sans-serif"), 725, false, &loaded);
|
||||
assert_eq!(resolved.family.as_ref(), "Inter");
|
||||
assert_eq!(resolved.shape_weight, 400);
|
||||
assert_eq!(resolved.variation_weight, Some(725));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_metadata_keeps_fill_and_variable_weight_independent() {
|
||||
let attrs = SpanAttrs {
|
||||
font_size: 16.0,
|
||||
line_height: 18.0,
|
||||
weight: 400,
|
||||
variation_weight: Some(725),
|
||||
italic: false,
|
||||
underline: true,
|
||||
color: [1, 2, 3, 255],
|
||||
family: Arc::from(FAMILY),
|
||||
clip_fill: Some(37),
|
||||
};
|
||||
let metadata = attrs.to_attrs().metadata;
|
||||
assert_ne!(metadata & META_UNDERLINE, 0);
|
||||
assert_eq!(metadata_fill(metadata), Some(37));
|
||||
assert_eq!(metadata_variation_weight(metadata), Some(725));
|
||||
}
|
||||
|
||||
fn variable_font_fixture() -> Vec<u8> {
|
||||
let encoded: String = include_str!("../tests/fonts/obscura-vf-test.woff2.b64")
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_whitespace())
|
||||
.collect();
|
||||
let compressed = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.expect("decode variable-font fixture");
|
||||
wuff::decompress_woff2(&compressed).expect("decompress variable-font fixture")
|
||||
}
|
||||
|
||||
fn render_variable_weight(weight: u16) -> ((f32, f32), u64, Vec<u16>) {
|
||||
let mut engine = TextEngine::new_with_web_fonts(&[WebFont {
|
||||
data: variable_font_fixture(),
|
||||
family: Some("Obscura VF Test".to_string()),
|
||||
weight: Some((100, 900)),
|
||||
italic: Some(false),
|
||||
}]);
|
||||
let tree = obscura_dom::parse_html("<p id='copy'>MMMMMMMM</p>");
|
||||
let copy = tree.get_element_by_id("copy").unwrap();
|
||||
let mut style = LayoutStyle::default();
|
||||
style.display = Display::Block;
|
||||
style.font_family = Some("Obscura VF Test".to_string());
|
||||
style.font_weight = Some(weight.to_string());
|
||||
style.font_size = Some(64.0);
|
||||
style.line_height = Some(crate::LineHeight::Px(80.0));
|
||||
let styles = HashMap::from([(copy, style)]);
|
||||
let item = engine.try_build(&tree, copy, &styles).unwrap();
|
||||
let geometry = engine.measure(item, Some(600.0));
|
||||
engine.finalize(item, (0.0, 0.0), 600.0, None);
|
||||
let mut pixmap = tiny_skia::Pixmap::new(600, 100).unwrap();
|
||||
engine.paint_item(item, &mut pixmap, (0.0, 0.0));
|
||||
let ink = pixmap
|
||||
.pixels()
|
||||
.iter()
|
||||
.map(|pixel| u64::from(pixel.alpha()))
|
||||
.sum();
|
||||
let mut cached_weights: Vec<_> = engine
|
||||
.variable_swash
|
||||
.images
|
||||
.keys()
|
||||
.map(|key| key.weight)
|
||||
.collect();
|
||||
cached_weights.sort_unstable();
|
||||
cached_weights.dedup();
|
||||
(geometry, ink, cached_weights)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_wght_changes_true_outline_without_changing_layout_geometry() {
|
||||
let (regular_geometry, regular_ink, regular_cache) = render_variable_weight(400);
|
||||
let (black_geometry, black_ink, black_cache) = render_variable_weight(900);
|
||||
assert_eq!(regular_geometry, black_geometry);
|
||||
assert!(
|
||||
black_ink > regular_ink * 13 / 10,
|
||||
"wght=900 should carry substantially more raster ink: {regular_ink} vs {black_ink}"
|
||||
);
|
||||
assert_eq!(regular_cache, vec![400]);
|
||||
assert_eq!(black_cache, vec![900]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_glyph_cache_keys_include_weight_axis() {
|
||||
let mut engine = TextEngine::new_with_web_fonts(&[WebFont {
|
||||
data: variable_font_fixture(),
|
||||
family: Some("Obscura VF Test".to_string()),
|
||||
weight: Some((100, 900)),
|
||||
italic: Some(false),
|
||||
}]);
|
||||
let tree = obscura_dom::parse_html(
|
||||
"<p id='copy'><span id='regular'>M</span><span id='black'>M</span></p>",
|
||||
);
|
||||
let copy = tree.get_element_by_id("copy").unwrap();
|
||||
let regular = tree.get_element_by_id("regular").unwrap();
|
||||
let black = tree.get_element_by_id("black").unwrap();
|
||||
let mut base = LayoutStyle::default();
|
||||
base.display = Display::Block;
|
||||
base.font_family = Some("Obscura VF Test".to_string());
|
||||
base.font_size = Some(64.0);
|
||||
base.line_height = Some(crate::LineHeight::Px(80.0));
|
||||
let mut regular_style = base.clone();
|
||||
regular_style.display = Display::Inline;
|
||||
regular_style.font_weight = Some("400".to_string());
|
||||
let mut black_style = regular_style.clone();
|
||||
black_style.font_weight = Some("900".to_string());
|
||||
let styles = HashMap::from([
|
||||
(copy, base),
|
||||
(regular, regular_style),
|
||||
(black, black_style),
|
||||
]);
|
||||
let item = engine.try_build(&tree, copy, &styles).unwrap();
|
||||
engine.measure(item, Some(200.0));
|
||||
engine.finalize(item, (0.0, 0.0), 200.0, None);
|
||||
let mut pixmap = tiny_skia::Pixmap::new(200, 100).unwrap();
|
||||
engine.paint_item(item, &mut pixmap, (0.0, 0.0));
|
||||
let weights: std::collections::HashSet<_> = engine
|
||||
.variable_swash
|
||||
.images
|
||||
.keys()
|
||||
.map(|key| key.weight)
|
||||
.collect();
|
||||
assert_eq!(weights, std::collections::HashSet::from([400, 900]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user