Use page font metrics and indent first lines

This commit is contained in:
SGavrl committed 2026-07-31 03:31:12 +00:00
1 parent 04e3a667ec
commit 0acf4e1b06
4 files changed
+409 -35

No files matched your search

+17
View File
@@ -1572,6 +1572,7 @@ fn layout_dom_once(
container_type: crate::ContainerType, container_type: crate::ContainerType,
container_names: Vec<String>, container_names: Vec<String>,
text_align: Option<taffy::AlignItems>, text_align: Option<taffy::AlignItems>,
text_indent: crate::Dimension,
legacy_center: bool, legacy_center: bool,
visibility_hidden: bool, visibility_hidden: bool,
opacity_product: f32, opacity_product: f32,
@@ -1609,6 +1610,7 @@ fn layout_dom_once(
container_type: crate::ContainerType::Normal, container_type: crate::ContainerType::Normal,
container_names: Vec::new(), container_names: Vec::new(),
text_align: None, text_align: None,
text_indent: crate::Dimension::Px(0.0),
legacy_center: false, legacy_center: false,
visibility_hidden: false, visibility_hidden: false,
opacity_product: 1.0, opacity_product: 1.0,
@@ -1942,6 +1944,14 @@ fn layout_dom_once(
} }
} }
} }
match style.text_indent {
Some(indent) => {
let indent = indent.resolve(em_px, root_fs, vw, vh);
style.text_indent = Some(indent);
inh.text_indent = indent;
}
None => style.text_indent = Some(inh.text_indent),
}
inh.visibility_hidden = style.visibility_hidden.unwrap_or(inh.visibility_hidden); inh.visibility_hidden = style.visibility_hidden.unwrap_or(inh.visibility_hidden);
inh.opacity_product *= style.opacity.unwrap_or(1.0); inh.opacity_product *= style.opacity.unwrap_or(1.0);
style.effectively_invisible = inh.visibility_hidden || inh.opacity_product < 0.02; style.effectively_invisible = inh.visibility_hidden || inh.opacity_product < 0.02;
@@ -2071,6 +2081,7 @@ fn layout_dom_once(
let host_transform = style.text_transform; let host_transform = style.text_transform;
let host_italic = style.font_style_italic; let host_italic = style.font_style_italic;
let host_text_align = style.text_align; let host_text_align = style.text_align;
let host_text_indent = style.text_indent;
let host_invisible = style.effectively_invisible; let host_invisible = style.effectively_invisible;
let settle_pseudo = |pseudo: &mut crate::LayoutStyle| { let settle_pseudo = |pseudo: &mut crate::LayoutStyle| {
if let Some(expression) = pseudo.font_size_expression.as_deref() { if let Some(expression) = pseudo.font_size_expression.as_deref() {
@@ -2261,6 +2272,12 @@ fn layout_dom_once(
if pseudo.text_align.is_none() { if pseudo.text_align.is_none() {
pseudo.text_align = host_text_align; pseudo.text_align = host_text_align;
} }
if pseudo.text_indent.is_none() {
pseudo.text_indent = host_text_indent;
} else if let Some(indent) = pseudo.text_indent {
pseudo.text_indent =
Some(indent.resolve(pseudo_em, root_fs, vw, vh));
}
pseudo.effectively_invisible = host_invisible; pseudo.effectively_invisible = host_invisible;
}; };
if let Some(pseudo) = style.before_pseudo.as_deref_mut() { if let Some(pseudo) = style.before_pseudo.as_deref_mut() {
+301 -35
View File
@@ -93,15 +93,25 @@ struct LoadedFamily {
struct LoadedFace { struct LoadedFace {
name: Arc<str>, name: Arc<str>,
font_id: Option<cosmic_text::fontdb::ID>, font_id: Option<cosmic_text::fontdb::ID>,
metrics: FaceMetrics,
min_weight: u16, min_weight: u16,
max_weight: u16, max_weight: u16,
italic: bool, italic: bool,
} }
#[derive(Clone, Copy, Debug, PartialEq)]
struct FaceMetrics {
ascent: f32,
descent: f32,
line_gap: f32,
units_per_em: f32,
}
#[derive(Clone)] #[derive(Clone)]
struct ResolvedFont { struct ResolvedFont {
family: Arc<str>, family: Arc<str>,
font_id: Option<cosmic_text::fontdb::ID>, font_id: Option<cosmic_text::fontdb::ID>,
metrics: FaceMetrics,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -148,6 +158,7 @@ fn resolve_loaded_font(
return ResolvedFont { return ResolvedFont {
family: Arc::clone(&face.name), family: Arc::clone(&face.name),
font_id: face.font_id, font_id: face.font_id,
metrics: face.metrics,
}; };
} }
let available: Vec<_> = let available: Vec<_> =
@@ -160,6 +171,7 @@ fn resolve_loaded_font(
return ResolvedFont { return ResolvedFont {
family: Arc::clone(&face.name), family: Arc::clone(&face.name),
font_id: face.font_id, font_id: face.font_id,
metrics: face.metrics,
}; };
} }
} }
@@ -169,6 +181,7 @@ fn resolve_loaded_font(
ResolvedFont { ResolvedFont {
family: Arc::from(fallback), family: Arc::from(fallback),
font_id: None, font_id: None,
metrics: bundled_face_metrics(fallback),
} }
} }
@@ -221,19 +234,48 @@ fn match_font_weight(requested: u16, available: &[u16]) -> u16 {
/// Chromium, not 11px. Keep these metrics beside the embedded faces so normal /// Chromium, not 11px. Keep these metrics beside the embedded faces so normal
/// line boxes follow the same device-pixel rhythm without consulting host /// line boxes follow the same device-pixel rhythm without consulting host
/// fonts. /// fonts.
fn normal_line_height(font_size: f32, family: &str) -> f32 { fn bundled_face_metrics(family: &str) -> FaceMetrics {
let (ascent, descent, line_gap) = match family { let (ascent, descent, line_gap) = match family {
SERIF_FAMILY => (1825.0, 443.0, 87.0), SERIF_FAMILY => (1825.0, 443.0, 87.0),
MONO_FAMILY => (1705.0, 615.0, 0.0), MONO_FAMILY => (1705.0, 615.0, 0.0),
_ => (1854.0, 434.0, 67.0), _ => (1854.0, 434.0, 67.0),
}; };
let scale = font_size / 2048.0; FaceMetrics {
(ascent * scale).round() + (descent * scale).round() + (line_gap * scale).round() ascent,
descent,
line_gap,
units_per_em: 2048.0,
}
} }
/// Computed used line-height shared by shaped inline runs and forced-break fn normal_line_height(font_size: f32, metrics: FaceMetrics) -> f32 {
/// sentinels that cannot join a run. let scale = font_size / metrics.units_per_em.max(1.0);
pub(crate) fn used_line_height(style: &LayoutStyle) -> f32 { (metrics.ascent * scale).round()
+ (metrics.descent * scale).round()
+ (metrics.line_gap * scale).round()
}
fn font_metrics(
db: &cosmic_text::fontdb::Database,
id: cosmic_text::fontdb::ID,
) -> Option<FaceMetrics> {
db.with_face_data(id, |data, face_index| {
let face = cosmic_text::ttf_parser::Face::parse(data, face_index).ok()?;
Some(FaceMetrics {
ascent: face.ascender().max(0) as f32,
descent: -(face.descender().min(0) as f32),
line_gap: face.line_gap().max(0) as f32,
units_per_em: face.units_per_em() as f32,
})
})
.flatten()
}
fn used_line_height_for_font(style: &LayoutStyle, font: &ResolvedFont) -> f32 {
used_line_height_with_metrics(style, font.metrics)
}
fn used_line_height_with_metrics(style: &LayoutStyle, metrics: FaceMetrics) -> f32 {
let font_size = style.font_size.unwrap_or(16.0); let font_size = style.font_size.unwrap_or(16.0);
match style.line_height { match style.line_height {
Some(crate::LineHeight::Px(px)) => px, Some(crate::LineHeight::Px(px)) => px,
@@ -245,13 +287,17 @@ pub(crate) fn used_line_height(style: &LayoutStyle) -> f32 {
_ => font_size, _ => font_size,
}, },
}, },
None | Some(crate::LineHeight::Normal) => normal_line_height( None | Some(crate::LineHeight::Normal) => normal_line_height(font_size, metrics),
font_size,
resolve_font_family(style.font_family.as_deref()),
),
} }
} }
/// Computed used line-height shared by shaped inline runs and forced-break
/// sentinels that cannot join a run.
pub(crate) fn used_line_height(style: &LayoutStyle) -> f32 {
let family = resolve_font_family(style.font_family.as_deref());
used_line_height_with_metrics(style, bundled_face_metrics(family))
}
type ClipTextFill = (f32, Vec<([u8; 4], Option<f32>)>); type ClipTextFill = (f32, Vec<([u8; 4], Option<f32>)>);
/// Per-glyph flags carried through cosmic-text's metadata. The common /// Per-glyph flags carried through cosmic-text's metadata. The common
@@ -278,6 +324,14 @@ fn metadata_variation(metadata: usize) -> Option<usize> {
/// paint it (filled in after layout). /// paint it (filled in after layout).
pub struct InlineItem { pub struct InlineItem {
buffer: Buffer, buffer: Buffer,
/// Unmodified shaped content retained only when `text-indent` is nonzero.
/// Each Taffy measurement can then derive a first-line-only wrap boundary
/// without accumulating synthetic hard breaks across repeated probes.
source_buffer: Option<Buffer>,
text_indent: Dimension,
/// Used LTR paint offset for the first formatted line at the most recently
/// shaped width. Later lines retain the ordinary content-box origin.
first_line_offset: f32,
/// Whether final shaping should tighten the wrap width while preserving /// Whether final shaping should tighten the wrap width while preserving
/// the natural line count (`text-wrap-style: balance`). /// the natural line count (`text-wrap-style: balance`).
balance_wrap: bool, balance_wrap: bool,
@@ -716,6 +770,8 @@ impl TextEngine {
.map(|(name, _)| Arc::<str>::from(name.as_str())) .map(|(name, _)| Arc::<str>::from(name.as_str()))
.unwrap_or_else(|| Arc::from(FAMILY)); .unwrap_or_else(|| Arc::from(FAMILY));
let shape_weight = face.weight.0; let shape_weight = face.weight.0;
let metrics = font_metrics(&db, id)
.unwrap_or_else(|| bundled_face_metrics(internal_name.as_ref()));
let italic = declared_italic let italic = declared_italic
.unwrap_or(!matches!(face.style, cosmic_text::fontdb::Style::Normal)); .unwrap_or(!matches!(face.style, cosmic_text::fontdb::Style::Normal));
let weight = declared_weight.unwrap_or((shape_weight, shape_weight)); let weight = declared_weight.unwrap_or((shape_weight, shape_weight));
@@ -729,6 +785,7 @@ impl TextEngine {
family.faces.push(LoadedFace { family.faces.push(LoadedFace {
name: Arc::clone(&internal_name), name: Arc::clone(&internal_name),
font_id: Some(id), font_id: Some(id),
metrics,
min_weight: weight.0, min_weight: weight.0,
max_weight: weight.1, max_weight: weight.1,
italic, italic,
@@ -783,6 +840,7 @@ impl TextEngine {
&self.loaded_families, &self.loaded_families,
); );
let ctx = base_span_ctx(base, font, &mut collector); let ctx = base_span_ctx(base, font, &mut collector);
let line_height = ctx.line_height;
let mut spans: Vec<(String, SpanAttrs)> = Vec::new(); let mut spans: Vec<(String, SpanAttrs)> = Vec::new();
collect_spans( collect_spans(
tree, tree,
@@ -793,7 +851,7 @@ impl TextEngine {
&mut collector, &mut collector,
&self.loaded_families, &self.loaded_families,
); );
self.push_shaped_item(base, spans, collector.clip_fills) self.push_shaped_item(base, line_height, spans, collector.clip_fills)
} }
/// Build an inline formatting context from a *run* of consecutive /// Build an inline formatting context from a *run* of consecutive
@@ -828,6 +886,7 @@ impl TextEngine {
&self.loaded_families, &self.loaded_families,
); );
let ctx = base_span_ctx(base, font, &mut collector); let ctx = base_span_ctx(base, font, &mut collector);
let line_height = ctx.line_height;
let mut spans: Vec<(String, SpanAttrs)> = Vec::new(); let mut spans: Vec<(String, SpanAttrs)> = Vec::new();
for &cid in run { for &cid in run {
collect_node_spans( collect_node_spans(
@@ -840,7 +899,7 @@ impl TextEngine {
&self.loaded_families, &self.loaded_families,
); );
} }
self.push_shaped_item(base, spans, collector.clip_fills) self.push_shaped_item(base, line_height, spans, collector.clip_fills)
} }
/// Shape generated text that owns a positioned pseudo box. /// Shape generated text that owns a positioned pseudo box.
@@ -866,6 +925,7 @@ impl TextEngine {
&self.loaded_families, &self.loaded_families,
); );
let context = base_span_ctx(style, font, &mut collector); let context = base_span_ctx(style, font, &mut collector);
let line_height = context.line_height;
let attrs = SpanAttrs { let attrs = SpanAttrs {
font_size: context.font_size, font_size: context.font_size,
line_height: context.line_height, line_height: context.line_height,
@@ -890,7 +950,7 @@ impl TextEngine {
&mut spans, &mut spans,
&mut collector, &mut collector,
); );
self.push_shaped_item(style, spans, collector.clip_fills) self.push_shaped_item(style, line_height, spans, collector.clip_fills)
} }
/// Shared tail of [`try_build`] / [`try_build_run`]: shape the collected /// Shared tail of [`try_build`] / [`try_build_run`]: shape the collected
@@ -899,6 +959,7 @@ impl TextEngine {
fn push_shaped_item( fn push_shaped_item(
&mut self, &mut self,
base: &LayoutStyle, base: &LayoutStyle,
line_h: f32,
mut spans: Vec<(String, SpanAttrs)>, mut spans: Vec<(String, SpanAttrs)>,
clip_fills: Vec<ClipTextFill>, clip_fills: Vec<ClipTextFill>,
) -> Option<usize> { ) -> Option<usize> {
@@ -936,7 +997,6 @@ impl TextEngine {
// Explicit line-height stays fractional. `normal` is derived from the // Explicit line-height stays fractional. `normal` is derived from the
// embedded face metrics with the same per-component pixel fitting as // embedded face metrics with the same per-component pixel fitting as
// Chromium's Linux font path. // Chromium's Linux font path.
let line_h = used_line_height(base);
let mut forced_breaks = 0usize; let mut forced_breaks = 0usize;
let mut visible_after_last_break = false; let mut visible_after_last_break = false;
for (text, _) in &spans { for (text, _) in &spans {
@@ -1025,8 +1085,14 @@ impl TextEngine {
} }
let idx = self.items.len(); let idx = self.items.len();
let text_indent = base.text_indent.unwrap_or(Dimension::Px(0.0));
let source_buffer = (!matches!(text_indent, Dimension::Px(value) if value == 0.0))
.then(|| buffer.clone());
self.items.push(InlineItem { self.items.push(InlineItem {
buffer, buffer,
source_buffer,
text_indent,
first_line_offset: 0.0,
balance_wrap: base.text_wrap_style == Some(crate::TextWrapStyle::Balance), balance_wrap: base.text_wrap_style == Some(crate::TextWrapStyle::Balance),
align, align,
forced_min_height, forced_min_height,
@@ -1049,9 +1115,8 @@ impl TextEngine {
} }
let TextEngine { font_system, items, .. } = self; let TextEngine { font_system, items, .. } = self;
let item = &mut items[idx]; let item = &mut items[idx];
item.buffer.set_size(font_system, width.map(|w| w.max(0.0)), None); shape_with_text_indent(font_system, item, width);
item.buffer.shape_until_scroll(font_system, false); let (width, height) = buffer_size(&item.buffer, item.first_line_offset);
let (width, height) = buffer_size(&item.buffer);
(width, height.max(item.forced_min_height)) (width, height.max(item.forced_min_height))
} }
@@ -1114,8 +1179,7 @@ impl TextEngine {
} = self; } = self;
let item = &mut items[idx]; let item = &mut items[idx];
let content_width = content_width.max(0.0); let content_width = content_width.max(0.0);
item.buffer.set_size(font_system, Some(content_width), None); shape_with_text_indent(font_system, item, Some(content_width));
item.buffer.shape_until_scroll(font_system, false);
let balanced_width = item let balanced_width = item
.balance_wrap .balance_wrap
.then(|| balance_wrap_width(font_system, &mut item.buffer, content_width)) .then(|| balance_wrap_width(font_system, &mut item.buffer, content_width))
@@ -1213,6 +1277,7 @@ struct SpanCtx {
weight: u16, weight: u16,
optical_sizing: crate::FontOpticalSizing, optical_sizing: crate::FontOpticalSizing,
font_id: Option<cosmic_text::fontdb::ID>, font_id: Option<cosmic_text::fontdb::ID>,
font_metrics: FaceMetrics,
variations: Option<Arc<FontVariations>>, variations: Option<Arc<FontVariations>>,
italic: bool, italic: bool,
underline: bool, underline: bool,
@@ -1267,15 +1332,17 @@ fn base_span_ctx(
index index
}); });
let variations = resolved_font_variations(base, &font); let variations = resolved_font_variations(base, &font);
let line_height = used_line_height_for_font(base, &font);
SpanCtx { SpanCtx {
font_size: base.font_size.unwrap_or(16.0), font_size: base.font_size.unwrap_or(16.0),
line_height: used_line_height(base), line_height,
letter_spacing: base.letter_spacing.unwrap_or(0.0), letter_spacing: base.letter_spacing.unwrap_or(0.0),
letter_spacing_non_normal: base.letter_spacing_non_normal.unwrap_or(false), letter_spacing_non_normal: base.letter_spacing_non_normal.unwrap_or(false),
color: base.color.unwrap_or([0, 0, 0, 255]), color: base.color.unwrap_or([0, 0, 0, 255]),
weight: crate::style::used_font_weight(base), weight: crate::style::used_font_weight(base),
optical_sizing: base.font_optical_sizing.unwrap_or_default(), optical_sizing: base.font_optical_sizing.unwrap_or_default(),
font_id: font.font_id, font_id: font.font_id,
font_metrics: font.metrics,
variations, variations,
italic: base.font_style_italic.unwrap_or(false), italic: base.font_style_italic.unwrap_or(false),
underline: base.underline.unwrap_or(false), underline: base.underline.unwrap_or(false),
@@ -1403,6 +1470,7 @@ fn collect_node_spans(
.unwrap_or_else(|| ResolvedFont { .unwrap_or_else(|| ResolvedFont {
family: Arc::clone(&ctx.family), family: Arc::clone(&ctx.family),
font_id: ctx.font_id, font_id: ctx.font_id,
metrics: ctx.font_metrics,
}); });
let variations = style let variations = style
.map(|style| resolved_font_variations(style, &font)) .map(|style| resolved_font_variations(style, &font))
@@ -1412,7 +1480,7 @@ fn collect_node_spans(
.and_then(|style| style.font_size) .and_then(|style| style.font_size)
.unwrap_or(ctx.font_size), .unwrap_or(ctx.font_size),
line_height: style line_height: style
.map(used_line_height) .map(|style| used_line_height_for_font(style, &font))
.unwrap_or(ctx.line_height), .unwrap_or(ctx.line_height),
letter_spacing: style letter_spacing: style
.and_then(|style| style.letter_spacing) .and_then(|style| style.letter_spacing)
@@ -1426,6 +1494,7 @@ fn collect_node_spans(
.and_then(|style| style.font_optical_sizing) .and_then(|style| style.font_optical_sizing)
.unwrap_or(ctx.optical_sizing), .unwrap_or(ctx.optical_sizing),
font_id: font.font_id, font_id: font.font_id,
font_metrics: font.metrics,
variations, variations,
italic: ctx.italic || style.and_then(|s| s.font_style_italic).unwrap_or(false), italic: ctx.italic || style.and_then(|s| s.font_style_italic).unwrap_or(false),
// Underline propagates in: an ancestor's underline covers // Underline propagates in: an ancestor's underline covers
@@ -1502,16 +1571,92 @@ fn push_text(
} }
/// Total shaped size of a buffer: widest line, and the bottom of the last line. /// Total shaped size of a buffer: widest line, and the bottom of the last line.
fn buffer_size(buffer: &Buffer) -> (f32, f32) { fn buffer_size(buffer: &Buffer, first_line_offset: f32) -> (f32, f32) {
let mut w = 0.0f32; let mut w = 0.0f32;
let mut h = 0.0f32; let mut h = 0.0f32;
for run in buffer.layout_runs() { for (line_index, run) in buffer.layout_runs().enumerate() {
w = w.max(run.line_w); let offset = if line_index == 0 {
first_line_offset
} else {
0.0
};
w = w.max((run.line_w + offset).max(0.0));
h = h.max(run.line_top + run.line_height); h = h.max(run.line_top + run.line_height);
} }
(w.ceil(), h) (w.ceil(), h)
} }
fn used_text_indent(value: Dimension, width: Option<f32>) -> f32 {
let indent = match value {
Dimension::Px(pixels) => pixels,
Dimension::Percent(fraction) => width.unwrap_or(0.0) * fraction,
_ => 0.0,
};
if indent.is_finite() { indent } else { 0.0 }
}
/// Shape one IFC with a first-line-only indent.
///
/// cosmic-text accepts one wrap width for every visual line. To retain the
/// browser model, first probe the pristine paragraph at `width - indent`, take
/// its first wrap boundary, split there, and reshape the tail at the full
/// content width. The synthetic split lives only in the working clone, so
/// repeated intrinsic/final Taffy measurements never accumulate hard breaks.
fn shape_with_text_indent(
font_system: &mut FontSystem,
item: &mut InlineItem,
width: Option<f32>,
) {
let indent = used_text_indent(item.text_indent, width);
item.first_line_offset = indent
* match item.align {
Some(Align::Center) => 0.5,
Some(Align::End | Align::Right) => 0.0,
_ => 1.0,
};
let Some(source) = item.source_buffer.as_ref() else {
item.buffer
.set_size(font_system, width.map(|value| value.max(0.0)), None);
item.buffer.shape_until_scroll(font_system, false);
return;
};
item.buffer = source.clone();
let mut first_break = None;
if let Some(full_width) = width {
item.buffer
.set_size(font_system, Some((full_width - indent).max(0.0)), None);
item.buffer.shape_until_scroll(font_system, false);
first_break = item
.buffer
.layout_runs()
.next()
.and_then(|run| run.glyphs.iter().map(|glyph| glyph.end).max());
}
item.buffer = source.clone();
if let (Some(mut split), Some(first_line)) =
(first_break, item.buffer.lines.first())
{
let text = first_line.text();
while split < text.len() {
let Some(ch) = text[split..].chars().next() else { break };
if !ch.is_whitespace() {
break;
}
split += ch.len_utf8();
}
if split > 0 && split < text.len() {
let tail = item.buffer.lines[0].split_off(split);
item.buffer.lines.insert(1, tail);
}
}
item.buffer
.set_size(font_system, width.map(|value| value.max(0.0)), None);
item.buffer.shape_until_scroll(font_system, false);
}
/// Chromium's bisection implementation only balances paragraphs of at most /// Chromium's bisection implementation only balances paragraphs of at most
/// six lines. Keeping the same cap bounds repeated shaping on long body copy /// six lines. Keeping the same cap bounds repeated shaping on long body copy
/// and confines this work to the heading-sized content the property targets. /// and confines this work to the heading-sized content the property targets.
@@ -1795,7 +1940,12 @@ impl TextEngine {
let mut underlines: Vec<(f32, f32, f32, f32, [u8; 4])> = Vec::new(); // x0, x1, y, thickness, color let mut underlines: Vec<(f32, f32, f32, f32, [u8; 4])> = Vec::new(); // x0, x1, y, thickness, color
let mut fill_bounds: Vec<Option<(f32, f32, f32, f32)>> = let mut fill_bounds: Vec<Option<(f32, f32, f32, f32)>> =
vec![None; item.clip_fills.len()]; vec![None; item.clip_fills.len()];
for run in item.buffer.layout_runs() { for (line_index, run) in item.buffer.layout_runs().enumerate() {
let line_offset = if line_index == 0 {
item.first_line_offset
} else {
0.0
};
let base_y = run.line_y; let base_y = run.line_y;
let mut seg: Option<(f32, f32, f32, [u8; 4])> = None; // x0, x1, font_size, color let mut seg: Option<(f32, f32, f32, [u8; 4])> = None; // x0, x1, font_size, color
for g in run.glyphs { for g in run.glyphs {
@@ -1803,9 +1953,9 @@ impl TextEngine {
if let Some(fill_index) = metadata_fill(g.metadata) { if let Some(fill_index) = metadata_fill(g.metadata) {
if let Some(bounds) = fill_bounds.get_mut(fill_index) { if let Some(bounds) = fill_bounds.get_mut(fill_index) {
let glyph_bounds = ( let glyph_bounds = (
g.x, g.x + line_offset,
run.line_top, run.line_top,
g.x + g.w, g.x + line_offset + g.w,
run.line_top + run.line_height, run.line_top + run.line_height,
); );
*bounds = Some(match *bounds { *bounds = Some(match *bounds {
@@ -1823,14 +1973,19 @@ impl TextEngine {
if underlined { if underlined {
match &mut seg { match &mut seg {
Some((_, x1, fs, c)) if *c == col => { Some((_, x1, fs, c)) if *c == col => {
*x1 = g.x + g.w; *x1 = g.x + line_offset + g.w;
*fs = fs.max(g.font_size); *fs = fs.max(g.font_size);
} }
_ => { _ => {
if let Some((x0, x1, fs, c)) = seg.take() { if let Some((x0, x1, fs, c)) = seg.take() {
underlines.push((x0, x1, base_y + (fs * 0.12).max(1.0), (fs / 14.0).max(1.0), c)); underlines.push((x0, x1, base_y + (fs * 0.12).max(1.0), (fs / 14.0).max(1.0), c));
} }
seg = Some((g.x, g.x + g.w, g.font_size, col)); seg = Some((
g.x + line_offset,
g.x + line_offset + g.w,
g.font_size,
col,
));
} }
} }
} else if let Some((x0, x1, fs, c)) = seg.take() { } else if let Some((x0, x1, fs, c)) = seg.take() {
@@ -1852,9 +2007,14 @@ impl TextEngine {
// over the entire heading and lose inline accent gradients. // over the entire heading and lose inline accent gradients.
let clip_fills = item.clip_fills.clone(); let clip_fills = item.clip_fills.clone();
let pixels = pixmap.pixels_mut(); let pixels = pixmap.pixels_mut();
for run in item.buffer.layout_runs() { for (line_index, run) in item.buffer.layout_runs().enumerate() {
let line_offset = if line_index == 0 {
item.first_line_offset
} else {
0.0
};
for glyph in run.glyphs { for glyph in run.glyphs {
let physical = glyph.physical((0.0, 0.0), 1.0); let physical = glyph.physical((line_offset, 0.0), 1.0);
let glyph_color = glyph.color_opt.unwrap_or(default); let glyph_color = glyph.color_opt.unwrap_or(default);
let fill_index = metadata_fill(glyph.metadata); let fill_index = metadata_fill(glyph.metadata);
let mut draw_pixel = |x, y, color: Color| { let mut draw_pixel = |x, y, color: Color| {
@@ -2004,10 +2164,62 @@ mod tests {
// Values measured from Chromium 145 using the same bundled Linux // Values measured from Chromium 145 using the same bundled Linux
// platform faces. Small fractional sizes expose the difference from // platform faces. Small fractional sizes expose the difference from
// rounding a single 1.15 multiplier. // rounding a single 1.15 multiplier.
assert_eq!(normal_line_height(9.3333, FAMILY), 10.0); assert_eq!(
assert_eq!(normal_line_height(12.0, FAMILY), 14.0); normal_line_height(9.3333, bundled_face_metrics(FAMILY)),
assert_eq!(normal_line_height(13.0, SERIF_FAMILY), 16.0); 10.0
assert_eq!(normal_line_height(13.0, MONO_FAMILY), 15.0); );
assert_eq!(
normal_line_height(12.0, bundled_face_metrics(FAMILY)),
14.0
);
assert_eq!(
normal_line_height(13.0, bundled_face_metrics(SERIF_FAMILY)),
16.0
);
assert_eq!(
normal_line_height(13.0, bundled_face_metrics(MONO_FAMILY)),
15.0
);
// Poppins's 1000-unit hhea metrics are 1050/-350 with a 100-unit
// gap. A 64px normal line is therefore 67 + 22 + 6 = 95px, rather
// than the 74px produced by the old generic-sans constants.
assert_eq!(
normal_line_height(
64.0,
FaceMetrics {
ascent: 1050.0,
descent: 350.0,
line_gap: 100.0,
units_per_em: 1000.0,
},
),
95.0
);
}
#[test]
fn declared_web_family_keeps_the_loaded_faces_line_metrics() {
let engine = TextEngine::new_with_web_fonts(&[WebFont {
data: FALLBACK.to_vec(),
family: Some("Page Face".to_string()),
weight: Some((400, 400)),
italic: Some(false),
}]);
let resolved =
resolve_loaded_font(Some("Page Face"), 400, false, &engine.loaded_families);
let expected = font_metrics(&engine.font_system.db(), resolved.font_id.unwrap()).unwrap();
assert_eq!(resolved.metrics, expected);
let style = LayoutStyle {
font_family: Some("Page Face".to_string()),
font_size: Some(64.0),
line_height: Some(crate::LineHeight::Normal),
..Default::default()
};
assert_eq!(
used_line_height_for_font(&style, &resolved),
normal_line_height(64.0, expected)
);
} }
#[test] #[test]
@@ -2128,6 +2340,7 @@ mod tests {
LoadedFace { LoadedFace {
name: Arc::from("Poppins"), name: Arc::from("Poppins"),
font_id: None, font_id: None,
metrics: bundled_face_metrics(FAMILY),
min_weight: 400, min_weight: 400,
max_weight: 400, max_weight: 400,
italic: false, italic: false,
@@ -2135,6 +2348,7 @@ mod tests {
LoadedFace { LoadedFace {
name: Arc::from("Poppins Medium"), name: Arc::from("Poppins Medium"),
font_id: None, font_id: None,
metrics: bundled_face_metrics(FAMILY),
min_weight: 500, min_weight: 500,
max_weight: 500, max_weight: 500,
italic: false, italic: false,
@@ -2142,6 +2356,7 @@ mod tests {
LoadedFace { LoadedFace {
name: Arc::from("Poppins"), name: Arc::from("Poppins"),
font_id: None, font_id: None,
metrics: bundled_face_metrics(FAMILY),
min_weight: 700, min_weight: 700,
max_weight: 700, max_weight: 700,
italic: false, italic: false,
@@ -2184,6 +2399,7 @@ mod tests {
faces: vec![LoadedFace { faces: vec![LoadedFace {
name: Arc::from(FAMILY), name: Arc::from(FAMILY),
font_id: Some(regular_id), font_id: Some(regular_id),
metrics: bundled_face_metrics(FAMILY),
min_weight: 100, min_weight: 100,
max_weight: 900, max_weight: 900,
italic: false, italic: false,
@@ -2289,6 +2505,7 @@ mod tests {
faces: vec![LoadedFace { faces: vec![LoadedFace {
name: Arc::from("Inter"), name: Arc::from("Inter"),
font_id: None, font_id: None,
metrics: bundled_face_metrics(FAMILY),
min_weight: 100, min_weight: 100,
max_weight: 900, max_weight: 900,
italic: false, italic: false,
@@ -2351,6 +2568,7 @@ mod tests {
let static_font = ResolvedFont { let static_font = ResolvedFont {
family: Arc::from(FAMILY), family: Arc::from(FAMILY),
font_id: None, font_id: None,
metrics: bundled_face_metrics(FAMILY),
}; };
assert!(resolved_font_variations(&style, &static_font).is_none()); assert!(resolved_font_variations(&style, &static_font).is_none());
let styles = HashMap::from([(copy, style)]); let styles = HashMap::from([(copy, style)]);
@@ -2379,6 +2597,7 @@ mod tests {
let font = ResolvedFont { let font = ResolvedFont {
family: Arc::from(FAMILY), family: Arc::from(FAMILY),
font_id: None, font_id: None,
metrics: bundled_face_metrics(FAMILY),
}; };
assert!(resolved_font_variations(&style, &font).is_none()); assert!(resolved_font_variations(&style, &font).is_none());
} }
@@ -2923,6 +3142,53 @@ gamma</div>"#,
.collect() .collect()
} }
#[test]
fn text_indent_changes_only_the_first_formatted_line() {
let tree = obscura_dom::parse_html(
"<p id='copy'>alpha beta gamma delta epsilon zeta eta theta</p>",
);
let copy = tree.get_element_by_id("copy").unwrap();
let style = LayoutStyle {
display: Display::Block,
font_size: Some(16.0),
line_height: Some(crate::LineHeight::Px(20.0)),
text_indent: Some(Dimension::Px(40.0)),
..Default::default()
};
let mut engine = TextEngine::new();
let item = engine
.try_build(&tree, copy, &HashMap::from([(copy, style)]))
.unwrap();
engine.finalize(item, (0.0, 0.0), 150.0, None);
let lines = shaped_line_texts(&engine.items[item].buffer);
assert!(lines.len() >= 2, "fixture must wrap: {lines:?}");
assert_eq!(engine.items[item].first_line_offset, 40.0);
let runs: Vec<_> = engine.items[item].buffer.layout_runs().collect();
let first_x = runs[0].glyphs.first().unwrap().x
+ engine.items[item].first_line_offset;
let second_x = runs[1].glyphs.first().unwrap().x;
assert!(
(first_x - 40.0).abs() < 0.01,
"first line must start at the authored indent: {first_x}"
);
assert!(
second_x.abs() < 0.01,
"continuation lines must return to the content edge: {second_x}"
);
let percentage = LayoutStyle {
display: Display::Block,
text_indent: Some(Dimension::Percent(0.25)),
..Default::default()
};
let percent_item = engine
.try_build(&tree, copy, &HashMap::from([(copy, percentage)]))
.unwrap();
engine.finalize(percent_item, (0.0, 0.0), 200.0, None);
assert_eq!(engine.items[percent_item].first_line_offset, 50.0);
}
#[test] #[test]
fn text_wrap_balance_changes_line_grouping_without_changing_line_count() { fn text_wrap_balance_changes_line_grouping_without_changing_line_count() {
let tree = obscura_dom::parse_html("<h1 id='hero'>Welcome to Mozilla</h1>"); let tree = obscura_dom::parse_html("<h1 id='hero'>Welcome to Mozilla</h1>");
+54
View File
@@ -980,6 +980,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
} }
_ => {} _ => {}
}, },
"text-indent" => apply_text_indent(style, value),
"align-items" => { "align-items" => {
if let Some(Some(value)) = self_alignment_value(value) { if let Some(Some(value)) = self_alignment_value(value) {
style.align_items = Some(value); style.align_items = Some(value);
@@ -1573,6 +1574,7 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool {
| "font-optical-sizing" | "font-optical-sizing"
| "font-variation-settings" | "font-variation-settings"
| "text-align" | "text-align"
| "text-indent"
| "text-transform" | "text-transform"
| "text-decoration" | "text-decoration"
| "text-decoration-line" | "text-decoration-line"
@@ -1712,6 +1714,9 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool {
"auto" | "wrap" | "balance" | "wrap balance" | "balance wrap" "auto" | "wrap" | "balance" | "wrap balance" | "balance wrap"
), ),
"text-wrap-style" => matches!(value.to_ascii_lowercase().as_str(), "auto" | "balance"), "text-wrap-style" => matches!(value.to_ascii_lowercase().as_str(), "auto" | "balance"),
"text-indent" => {
parse_text_indent(value).is_some()
}
"animation-duration" => parse_animation_time_ms(value).is_some_and(|time| time >= 0.0), "animation-duration" => parse_animation_time_ms(value).is_some_and(|time| time >= 0.0),
"animation-delay" => parse_animation_time_ms(value).is_some(), "animation-delay" => parse_animation_time_ms(value).is_some(),
"animation-iteration-count" => parse_animation_iteration_count(value).is_some(), "animation-iteration-count" => parse_animation_iteration_count(value).is_some(),
@@ -3908,6 +3913,29 @@ fn apply_letter_spacing(style: &mut LayoutStyle, value: &str) {
} }
} }
fn parse_text_indent(value: &str) -> Option<crate::Dimension> {
// The Level 3 `hanging` and `each-line` keywords change which lines are
// indented. Keep them invalid until those semantics are represented.
if split_ws_paren(value).len() != 1 {
return None;
}
let dimension = dimension_value(value);
(!matches!(dimension, crate::Dimension::Auto)).then_some(dimension)
}
fn apply_text_indent(style: &mut LayoutStyle, value: &str) {
match value.trim().to_ascii_lowercase().as_str() {
"initial" => style.text_indent = Some(crate::Dimension::Px(0.0)),
// This is an inherited property, so `unset` computes like `inherit`.
"inherit" | "unset" | "revert" | "revert-layer" => style.text_indent = None,
_ => {
if let Some(indent) = parse_text_indent(value) {
style.text_indent = Some(indent);
}
}
}
}
fn apply_gap_value(style: &mut LayoutStyle, row: bool, value: &str) { fn apply_gap_value(style: &mut LayoutStyle, row: bool, value: &str) {
let value = value.trim(); let value = value.trim();
let lower = value.to_ascii_lowercase(); let lower = value.to_ascii_lowercase();
@@ -5487,6 +5515,32 @@ mod tests {
assert_eq!(inherited.letter_spacing_non_normal, None); assert_eq!(inherited.letter_spacing_non_normal, None);
} }
#[test]
fn text_indent_preserves_lengths_percentages_and_inherited_resets() {
let relative = compute_style("p", Some("text-indent:2em"));
assert_eq!(relative.text_indent, Some(crate::Dimension::Em(2.0)));
let percentage = compute_style("p", Some("text-indent:25%"));
assert_eq!(
percentage.text_indent,
Some(crate::Dimension::Percent(0.25))
);
let inherited = compute_style("p", Some("text-indent:18px;text-indent:unset"));
assert_eq!(inherited.text_indent, None);
let initial = compute_style("p", Some("text-indent:18px;text-indent:initial"));
assert_eq!(initial.text_indent, Some(crate::Dimension::Px(0.0)));
let invalid = compute_style(
"p",
Some("text-indent:18px;text-indent:10px hanging"),
);
assert_eq!(invalid.text_indent, Some(crate::Dimension::Px(18.0)));
assert!(supports_declaration("text-indent", "-9999px"));
assert!(supports_declaration("text-indent", "25%"));
assert!(!supports_declaration("text-indent", "10px hanging"));
}
#[test] #[test]
fn nonzero_unitless_font_size_is_invalid() { fn nonzero_unitless_font_size_is_invalid() {
let inherited = compute_style("div", Some("font-size:14px;font-size:.813")); let inherited = compute_style("div", Some("font-size:14px;font-size:.813"));
@@ -2713,3 +2713,40 @@ fn negative_letter_spacing_changes_intrinsic_line_breaks() {
assert_eq!(tight.height, 80.0, "negative tracking must shape to two lines: {tight:?}"); assert_eq!(tight.height, 80.0, "negative tracking must shape to two lines: {tight:?}");
assert_eq!(reset.height, 120.0, "`normal` must reset inherited tracking: {reset:?}"); assert_eq!(reset.height, 120.0, "`normal` must reset inherited tracking: {reset:?}");
} }
#[test]
fn text_indent_is_inherited_and_initial_resets_to_zero() {
let tree = parse_html(
r#"
<style>
#parent { font-size:20px; text-indent:2em }
#initial { text-indent:initial }
#unset { text-indent:unset }
</style>
<section id="parent">
<p id="inherited">Inherited first line</p>
<p id="initial">Reset first line</p>
<p id="unset">Unset inherits</p>
</section>
"#,
);
let layout = layout_dom(&tree, (500.0, 300.0));
let style = |id| &layout.styles[&tree.get_element_by_id(id).unwrap()];
assert_eq!(
style("parent").text_indent,
Some(obscura_render::Dimension::Px(40.0))
);
assert_eq!(
style("inherited").text_indent,
Some(obscura_render::Dimension::Px(40.0))
);
assert_eq!(
style("initial").text_indent,
Some(obscura_render::Dimension::Px(0.0))
);
assert_eq!(
style("unset").text_indent,
Some(obscura_render::Dimension::Px(40.0))
);
}