diff --git a/examples/common/src/lib.rs b/examples/common/src/lib.rs index 01f841d24..f1ff17ad4 100644 --- a/examples/common/src/lib.rs +++ b/examples/common/src/lib.rs @@ -14,7 +14,7 @@ use std::time::Instant; use parley::fontique::Blob; use parley::{ Alignment, AlignmentOptions, FontContext, FontFamily, FontWeight, GenericFamily, InlineBox, - Layout, LayoutContext, LineHeight, StyleProperty, + Layout, LayoutContext, LineHeight, StyleProperty, AlignmentBaseline, BaselineShift, BaselineSource, }; use peniko::Color; @@ -209,6 +209,10 @@ pub fn build_rich_layout( index: 40, width: 50.0, height: 50.0, + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), + first_baseline: None, }); let mut layout = builder.build(&config.text); diff --git a/examples/swash_render/src/main.rs b/examples/swash_render/src/main.rs index 431a6c4f0..a49cad92f 100644 --- a/examples/swash_render/src/main.rs +++ b/examples/swash_render/src/main.rs @@ -10,7 +10,7 @@ use image::codecs::png::PngEncoder; use image::{self, Pixel, Rgba, RgbaImage}; use parley::layout::{Alignment, Glyph, GlyphRun, Layout, PositionedLayoutItem}; use parley::style::{FontFamily, FontWeight, StyleProperty, TextStyle}; -use parley::{AlignmentOptions, FontContext, InlineBox, LayoutContext, LineHeight}; +use parley::{AlignmentOptions, AlignmentBaseline, BaselineShift, BaselineSource, FontContext, InlineBox, LayoutContext, LineHeight}; use std::fs::File; use swash::FontRef; use swash::scale::image::Content; @@ -97,6 +97,10 @@ fn main() { index: 0, width: 50.0, height: 50.0, + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), + first_baseline: None, }); builder.push_text(&text[40..50]); @@ -106,6 +110,10 @@ fn main() { index: 50, width: 50.0, height: 30.0, + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), + first_baseline: None, }); builder.push_text(&text[50..141]); @@ -155,12 +163,20 @@ fn main() { index: 40, width: 50.0, height: 50.0, + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), + first_baseline: None, }); builder.push_inline_box(InlineBox { id: 1, index: 50, width: 50.0, height: 30.0, + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), + first_baseline: None, }); // Build the builder into a Layout diff --git a/examples/tiny_skia_render/src/main.rs b/examples/tiny_skia_render/src/main.rs index 359adc255..ecf867d50 100644 --- a/examples/tiny_skia_render/src/main.rs +++ b/examples/tiny_skia_render/src/main.rs @@ -11,7 +11,7 @@ use parley::{ Alignment, AlignmentOptions, FontContext, FontWeight, GenericFamily, GlyphRun, InlineBox, - Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty, + Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty, AlignmentBaseline, BaselineShift, BaselineSource, }; use skrifa::{ GlyphId, MetadataProvider, OutlineGlyph, @@ -91,6 +91,10 @@ fn main() { index: 40, width: 50.0, height: 50.0, + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), + first_baseline: None, }); // Build the builder into a Layout diff --git a/parley/src/builder.rs b/parley/src/builder.rs index 526eae77b..0ca17acaf 100644 --- a/parley/src/builder.rs +++ b/parley/src/builder.rs @@ -146,6 +146,7 @@ impl StyleRunBuilder<'_, B> { self.cursor == self.len, "StyleRunBuilder requires runs that cover the full text" ); + build_into_layout( layout, self.scale, diff --git a/parley/src/inline_box.rs b/parley/src/inline_box.rs index 16a7a87b1..ad247e5fc 100644 --- a/parley/src/inline_box.rs +++ b/parley/src/inline_box.rs @@ -1,6 +1,8 @@ // Copyright 2024 the Parley Authors // SPDX-License-Identifier: Apache-2.0 OR MIT +use crate::{AlignmentBaseline, BaselineShift, BaselineSource}; + /// A box to be laid out inline with text #[derive(PartialEq, Debug, Clone)] pub struct InlineBox { @@ -14,4 +16,16 @@ pub struct InlineBox { pub width: f32, /// The height of the box in pixels pub height: f32, + /// Which baseline to align to (CSS `alignment-baseline`). + pub alignment_baseline: AlignmentBaseline, + /// How much to shift from the alignment baseline (CSS `baseline-shift`). + pub baseline_shift: BaselineShift, + /// Which baseline set to use (CSS `baseline-source`). + pub baseline_source: BaselineSource, + /// The distance from the top of the box to its internal text baseline, if the + /// box contains text content. When `Some(baseline)`, the box aligns using this + /// as its baseline (giving separate "ascent" and "descent" portions). When `None`, + /// falls back to aligning by the bottom of the box (the entire height is treated + /// as ascent above the baseline). In Blitz, this is sourced from Taffy's layout output. + pub first_baseline: Option, } diff --git a/parley/src/layout/data.rs b/parley/src/layout/data.rs index b5c4a872e..e746e15d9 100644 --- a/parley/src/layout/data.rs +++ b/parley/src/layout/data.rs @@ -6,6 +6,7 @@ use crate::layout::{ContentWidths, Glyph, LineMetrics, RunMetrics, Style}; use crate::style::Brush; use crate::util::nearly_zero; use crate::{FontData, IndentOptions, LineHeight, OverflowWrap, TextWrapMode}; +use skrifa::raw::TableProvider; use core::ops::Range; use alloc::vec::Vec; @@ -189,6 +190,9 @@ pub(crate) struct LineItemData { /// Advance (size in direction of text flow) for the run. pub(crate) advance: f32, + /// Offset from the line baseline for vertical alignment (positive = down). + pub(crate) baseline_offset: f32, + // Fields that only apply to text runs (Ignored for boxes) // TODO: factor this out? /// True if the run is composed entirely of whitespace. @@ -302,6 +306,17 @@ pub(crate) struct LayoutData { pub(crate) indent_amount: f32, /// Options controlling text-indent behavior (each-line, hanging). pub(crate) indent_options: IndentOptions, + + // CSS strut metrics (CSS2.1 §10.8.1): the root style's font metrics + // that establish minimum line box height for every line. + /// Strut font ascent (from root style's font). + pub(crate) strut_ascent: f32, + /// Strut font descent (from root style's font). + pub(crate) strut_descent: f32, + /// Strut line height (from root style's line-height). + pub(crate) strut_line_height: f32, + /// Strut x-height (from root style's font, for vertical-align: middle). + pub(crate) strut_x_height: f32, } impl Default for LayoutData { @@ -330,6 +345,10 @@ impl Default for LayoutData { alignment_width: 0.0, indent_amount: 0.0, indent_options: IndentOptions::default(), + strut_ascent: 0.0, + strut_descent: 0.0, + strut_line_height: 0.0, + strut_x_height: 0.0, } } } @@ -353,6 +372,10 @@ impl LayoutData { self.glyphs.clear(); self.lines.clear(); self.line_items.clear(); + self.strut_ascent = 0.0; + self.strut_descent = 0.0; + self.strut_line_height = 0.0; + self.strut_x_height = 0.0; } /// Push an inline box to the list of items @@ -398,10 +421,27 @@ impl LayoutData { index }); - let metrics = { + let (metrics, subscript_offset, superscript_offset, os2_win_line_height) = { let font = &self.fonts[font_index]; let font_ref = skrifa::FontRef::from_index(font.data.as_ref(), font.index).unwrap(); - skrifa::metrics::Metrics::new(&font_ref, skrifa::prelude::Size::new(font_size), coords) + let m = skrifa::metrics::Metrics::new( + &font_ref, + skrifa::prelude::Size::new(font_size), + coords, + ); + let scale = font_size / m.units_per_em as f32; + // Read subscript/superscript offsets from the OS/2 table + let os2 = font_ref.os2().ok(); + let sub_off = os2.as_ref().map(|t| t.y_subscript_y_offset() as f32 * scale); + let sup_off = os2.as_ref().map(|t| t.y_superscript_y_offset() as f32 * scale); + // OS/2 Win metrics (usWinAscent + usWinDescent) are often larger than + // sTypo/hhea metrics and closer to what browsers use for line-height: normal. + let os2_win_lh = os2.as_ref().map(|t| { + let win_ascent = t.us_win_ascent() as f32 * scale; + let win_descent = t.us_win_descent() as f32 * scale; + win_ascent + win_descent + }); + (m, sub_off, sup_off, os2_win_lh) }; let units_per_em = metrics.units_per_em as f32; @@ -427,7 +467,12 @@ impl LayoutData { LineHeight::Absolute(value) => value, LineHeight::FontSizeRelative(value) => value * font_size, LineHeight::MetricsRelative(value) => { - (metrics.ascent - metrics.descent + metrics.leading) * value + // Prefer OS/2 Win metrics (usWinAscent + usWinDescent) when + // available — they're closer to what browsers use for + // line-height: normal. Fall back to skrifa's default metrics. + let base = os2_win_line_height + .unwrap_or(metrics.ascent - metrics.descent + metrics.leading); + base * value } }; @@ -442,6 +487,8 @@ impl LayoutData { line_height, x_height: metrics.x_height, cap_height: metrics.cap_height, + subscript_offset, + superscript_offset, } }; diff --git a/parley/src/layout/layout.rs b/parley/src/layout/layout.rs index 8a1adbf59..46b5e4316 100644 --- a/parley/src/layout/layout.rs +++ b/parley/src/layout/layout.rs @@ -111,6 +111,24 @@ impl Layout { }) } + /// Sets the CSS strut metrics for the layout (CSS2.1 §10.8.1). + /// + /// The strut is an imaginary zero-width inline box on each line with the root + /// element's font metrics and line-height. It establishes the minimum line box + /// height, ensuring that lines containing only inline boxes (e.g. icons) still + /// get the height dictated by the parent's font. + /// + /// All values should be in physical pixels (i.e. already scaled by the device + /// pixel ratio). + /// + /// This must be called before [`Layout::break_all_lines`] or [`Layout::break_lines`]. + pub fn set_strut(&mut self, ascent: f32, descent: f32, line_height: f32, x_height: f32) { + self.data.strut_ascent = ascent; + self.data.strut_descent = descent; + self.data.strut_line_height = line_height; + self.data.strut_x_height = x_height; + } + /// Sets the text-indent for the layout. /// /// The indent is applied as a margin on the start edge of indented lines, reducing the diff --git a/parley/src/layout/line.rs b/parley/src/layout/line.rs index d9761f5a4..c5e20893c 100644 --- a/parley/src/layout/line.rs +++ b/parley/src/layout/line.rs @@ -93,6 +93,17 @@ impl<'a, B: Brush> Line<'a, B> { }) } + /// Returns the baseline offset for the item at the given line-relative index. + fn baseline_offset(&self, item_index: usize) -> f32 { + let abs_idx = self.data.item_range.start + item_index; + self.layout + .data + .line_items + .get(abs_idx) + .map(|li| li.baseline_offset) + .unwrap_or(0.0) + } + /// Returns an iterator over the glyph runs for the line. pub fn items(&self) -> impl Iterator> + 'a + Clone { GlyphRunIter { @@ -252,13 +263,21 @@ impl<'a, B: Brush> Iterator for GlyphRunIter<'a, B> { match item { LineItem::InlineBox(inline_box) => { let x = self.offset + self.line.data.metrics.offset; + let baseline_offset = self.line.baseline_offset(self.item_index); self.item_index += 1; self.glyph_start = 0; self.offset += inline_box.width; + let y = self.line.data.metrics.baseline + baseline_offset - inline_box.height; + // #[cfg(feature = "std")] + // std::eprintln!( + // "[pos ibox] id={} x={:.1} y={:.1} w={:.1} h={:.1} baseline={:.1} offset={:.1}", + // inline_box.id, x, y, inline_box.width, inline_box.height, + // self.line.data.metrics.baseline, baseline_offset + // ); return Some(PositionedLayoutItem::InlineBox(PositionedInlineBox { x, - y: self.line.data.metrics.baseline - inline_box.height, + y, width: inline_box.width, height: inline_box.height, id: inline_box.id, @@ -279,6 +298,8 @@ impl<'a, B: Brush> Iterator for GlyphRunIter<'a, B> { advance += glyph.advance; } let style = run.layout.data.styles.get(style_index)?; + let baseline_offset = self.line.baseline_offset(self.item_index); + let glyph_start = self.glyph_start; self.glyph_start += glyph_count; let offset = self.offset; @@ -289,7 +310,7 @@ impl<'a, B: Brush> Iterator for GlyphRunIter<'a, B> { glyph_start, glyph_count, offset: offset + self.line.data.metrics.offset, - baseline: self.line.data.metrics.baseline, + baseline: self.line.data.metrics.baseline + baseline_offset, advance, })); } diff --git a/parley/src/layout/line_break.rs b/parley/src/layout/line_break.rs index 5d3d70de7..34d73048f 100644 --- a/parley/src/layout/line_break.rs +++ b/parley/src/layout/line_break.rs @@ -17,7 +17,7 @@ use crate::layout::{ LineMetrics, Run, }; use crate::style::Brush; -use crate::{OverflowWrap, TextWrapMode}; +use crate::{AlignmentBaseline, BaselineShift, OverflowWrap, TextWrapMode}; use core::ops::Range; @@ -688,68 +688,338 @@ impl<'a, B: Brush> BreakLines<'a, B> { line.metrics.offset = 0.; line.text_range.start = usize::MAX; - line.metrics.line_height = line_height; + // Track the max inline box extent above/below the baseline for the + // CSS line box height calculation. For text runs, the CSS inline box + // height = line-height, split as (ascent + half_leading) above the baseline + // and (descent + half_leading) below. When vertical-align shifts a run, the + // shifted inline box can push the line box extent beyond the nominal + // line_height. We track this to expand the line box only for shifts, NOT + // for intentionally small line-heights (where content overflows the inline box). + let mut max_inline_box_above: f32 = f32::NEG_INFINITY; + let mut max_inline_box_below: f32 = f32::NEG_INFINITY; + // Track max individual inline-block extent (ascent + descent per box). + let mut max_ibox_extent: f32 = 0.0; + + // Apply CSS strut: line_height is at least the root style's line_height + line.metrics.line_height = line_height.max(self.layout.data.strut_line_height); if line.item_range.is_empty() { line.text_range = self.layout.data.text_len..self.layout.data.text_len; } - // Compute metrics for the line, but ignore trailing whitespace. + // Two-pass vertical alignment algorithm: + // + // Pass 0: Pre-compute whitespace properties, text ranges, advance, bidi, + // and identify trailing whitespace (which doesn't contribute to metrics). + // + // Pass 1: Process baseline-relative items (everything except Top/Bottom). + // Compute each item's baseline_offset and accumulate line ascent/descent + // accounting for shifted positions. + // + // Pass 2: Process Top/Bottom items now that line metrics are finalized. + let mut have_metrics = false; let mut needs_reorder = false; - for line_item in self.lines.line_items[line.item_range.clone()] - .iter_mut() - .rev() - { - match line_item.kind { - LayoutItemKind::InlineBox => { - let item = &self.layout.data.inline_boxes[line_item.index]; - // Advance is already computed in "commit line" for items + // Index of the first trailing-whitespace-only text run (from the end). + // Items at or after this index are trailing whitespace and don't contribute to metrics. + let mut trailing_ws_start = line.item_range.end; - // Default vertical alignment is to align the bottom of boxes with the text baseline. - // This is equivalent to the entire height of the box being "ascent" - line.metrics.ascent = line.metrics.ascent.max(item.height); + // Collect the line's x-height from text runs for use in inline box Middle alignment. + // CSS spec fallback for x-height is font_size * 0.5: + // https://www.w3.org/TR/css-inline-3/#baseline-synthesis-fonts + let mut line_x_height: Option = None; - // Mark us as having seen non-whitespace content on this line - have_metrics = true; - } + // Pre-compute the "dominant baseline" ascent/descent from baseline-aligned text runs. + // This is needed for TextTop/TextBottom alignment, which must reference the final + // line metrics from unshifted runs rather than the still-accumulating metrics. + let mut dominant_ascent = 0.0_f32; + let mut dominant_descent = 0.0_f32; + + // Pass 0: pre-compute per-item properties and find trailing whitespace boundary + for item_idx in line.item_range.clone() { + let line_item = &mut self.lines.line_items[item_idx]; + match line_item.kind { + LayoutItemKind::InlineBox => {} LayoutItemKind::TextRun => { line_item.compute_whitespace_properties(&self.layout.data); - // Compute the text range for the line - // Q: Can we not simplify this computation by assuming that items are in order? line.text_range.end = line.text_range.end.max(line_item.text_range.end); line.text_range.start = line.text_range.start.min(line_item.text_range.start); - // Mark line as needing bidi re-ordering if it contains any runs with non-zero bidi level - // (zero is the default level, so this is equivalent to marking lines that have multiple levels) if line_item.bidi_level != 0 { needs_reorder = true; } - // Compute the run's advance by summing the advances of its constituent clusters line_item.advance = self.layout.data.clusters[line_item.cluster_range.clone()] .iter() .map(|c| c.advance) .sum(); - // Ignore trailing whitespace for metrics computation - // (we are iterating backwards so trailing whitespace comes first) - if !have_metrics && line_item.is_whitespace { - continue; + let run = &self.layout.data.runs[line_item.index]; + + // Collect x-height from the first text run that has one + if line_x_height.is_none() { + line_x_height = Some(run.metrics.x_height.unwrap_or(run.font_size * 0.5)); + } + + // Accumulate dominant baseline metrics from baseline-aligned runs + let (ab, bs) = get_run_alignment(line_item, &self.layout.data); + if ab == AlignmentBaseline::Baseline && bs == BaselineShift::None { + dominant_ascent = dominant_ascent.max(run.metrics.ascent); + dominant_descent = dominant_descent.max(run.metrics.descent); + } + } + } + } + + // Fall back to strut x-height if there are no text runs on the line (e.g. boxes-only line). + // This ensures vertical-align: middle works correctly even without text. + let line_x_height = line_x_height.unwrap_or(self.layout.data.strut_x_height); + + // Walk backwards to find trailing whitespace boundary + for item_idx in line.item_range.clone().rev() { + let line_item = &self.lines.line_items[item_idx]; + match line_item.kind { + LayoutItemKind::InlineBox => break, // Inline box ends trailing whitespace + LayoutItemKind::TextRun => { + if line_item.is_whitespace { + trailing_ws_start = item_idx; + } else { + break; } + } + } + } - // Compute the run's vertical metrics + // Pass 1: baseline-relative items (everything except Top/Bottom shift). + // + // Note on inline box metrics contribution: inline boxes position as + // y = baseline + offset - height, so box top = offset - height and + // box bottom = offset (relative to baseline). The effective ascent/descent + // formulas differ from text runs because boxes have a single height rather + // than separate ascent/descent, but the offset direction is consistent. + // TODO: When `first_baseline` is added to InlineBox, boxes will have + // separate "ascent" (first_baseline) and "descent" (height - first_baseline) + // portions, and these formulas will need updating. + for item_idx in line.item_range.clone() { + let is_trailing_ws = item_idx >= trailing_ws_start; + let line_item = &mut self.lines.line_items[item_idx]; + match line_item.kind { + LayoutItemKind::InlineBox => { + let item = &self.layout.data.inline_boxes[line_item.index]; + let is_line_relative = matches!( + item.baseline_shift, + BaselineShift::Top | BaselineShift::Bottom + ); + + // Compute alignment offset from alignment_baseline. + // + // For inline boxes, y = baseline + offset - height, so: + // box bottom = baseline + offset + // box center = baseline + offset - height/2 + // box top = baseline + offset - height + // + // When `first_baseline` is set, the box aligns its internal + // baseline with the line baseline. The offset shifts so that + // the box's internal baseline sits at the line baseline: + // offset = -(height - first_baseline) + // which is equivalent to: the box top is at baseline - first_baseline. + let box_ascent = item.first_baseline.unwrap_or(item.height); + + let align_offset = match item.alignment_baseline { + // Baseline: align box's baseline with line baseline. + // With first_baseline: offset so box_baseline = line_baseline + // Without: bottom of box sits at baseline (offset = 0) + AlignmentBaseline::Baseline => { + if item.first_baseline.is_some() { + // offset = height - first_baseline so the box's internal + // baseline aligns with the line baseline. + // y = baseline + offset - height = baseline - first_baseline + item.height - box_ascent + } else { + 0.0 + } + } + AlignmentBaseline::TextTop => -(dominant_ascent - item.height), + AlignmentBaseline::TextBottom => dominant_descent, + // CSS middle: center the box at baseline - x_height/2. + // offset = (height - x_height) / 2 + AlignmentBaseline::Middle => (item.height - line_x_height) / 2.0, + }; + + // Compute shift offset from baseline_shift. + // Note: Inline boxes don't have associated font metrics, so Sub/Super + // use hardcoded ratios of the box height. Once `first_baseline` is + // supported, we could derive better values from the box's content. + let shift_offset = match item.baseline_shift { + BaselineShift::None => 0.0, + BaselineShift::Sub => item.height * 0.25, + BaselineShift::Super => -(item.height * 0.4), + BaselineShift::Length(v) => -v, + // Top/Bottom deferred to pass 2 + BaselineShift::Top | BaselineShift::Bottom => 0.0, + }; + + let offset = align_offset + shift_offset; + line_item.baseline_offset = offset; + + // #[cfg(feature = "std")] + // std::eprintln!( + // "[pass1 ibox] id={} h={:.1} first_baseline={:?} align={:?} shift={:?} align_off={:.1} shift_off={:.1} offset={:.1} is_line_rel={}", + // item.id, item.height, item.first_baseline, + // item.alignment_baseline, item.baseline_shift, + // align_offset, shift_offset, offset, is_line_relative + // ); + + // Contribute to line metrics (skip Top/Bottom — they're resolved in pass 2). + // The box spans from (baseline - ascent) to (baseline + descent) + // where: + // ascent = max(0, height - offset) (space above baseline) + // descent = max(0, offset) (space below baseline) + // + // When a box is shifted (e.g. vertical-align: 50%), ascent can exceed + // item.height — the line box must expand to accommodate it. + // + // However, when there's NO shift and the excess comes solely from + // first_baseline extending beyond the box (e.g. a 4px wrapper inheriting + // a 56px line-height), we clamp to box dimensions for browser compat. + if !is_line_relative { + let raw_ascent = (item.height - offset).max(0.0); + let raw_descent = offset.max(0.0); + let (effective_ascent, effective_descent) = if shift_offset == 0.0 { + (raw_ascent.min(item.height), raw_descent.min(item.height)) + } else { + (raw_ascent, raw_descent) + }; + line.metrics.ascent = line.metrics.ascent.max(effective_ascent); + line.metrics.descent = line.metrics.descent.max(effective_descent); + max_ibox_extent = max_ibox_extent.max(effective_ascent + effective_descent); + // Inline-blocks contribute to the unified above/below tracking + // so their extent combines with text descent (and vice versa) + // when computing line box height. This handles cases like + // overflow:hidden boxes (all above baseline) + text descent. + max_inline_box_above = max_inline_box_above.max(effective_ascent); + max_inline_box_below = max_inline_box_below.max(effective_descent); + + // #[cfg(feature = "std")] + // std::eprintln!( + // "[pass1 ibox metrics] id={} raw_asc={:.1} raw_desc={:.1} eff_asc={:.1} eff_desc={:.1} clamped={} line_asc={:.1} line_desc={:.1}", + // item.id, + // raw_ascent, + // raw_descent, + // effective_ascent, + // effective_descent, + // shift_offset == 0.0, + // line.metrics.ascent, + // line.metrics.descent + // ); + } + + have_metrics = true; + } + LayoutItemKind::TextRun => { + let (alignment_baseline, baseline_shift) = + get_run_alignment(line_item, &self.layout.data); let run = &self.layout.data.runs[line_item.index]; - line.metrics.ascent = line.metrics.ascent.max(run.metrics.ascent); - line.metrics.descent = line.metrics.descent.max(run.metrics.descent); + let is_line_relative = + matches!(baseline_shift, BaselineShift::Top | BaselineShift::Bottom); + + // Compute alignment offset from alignment_baseline. + // TextTop/TextBottom use pre-computed dominant baseline metrics + // rather than the still-accumulating line.metrics values. + let align_offset = match alignment_baseline { + AlignmentBaseline::Baseline => 0.0, + AlignmentBaseline::TextTop => -(dominant_ascent - run.metrics.ascent), + AlignmentBaseline::TextBottom => dominant_descent - run.metrics.descent, + AlignmentBaseline::Middle => { + // CSS spec fallback: font_size * 0.5 + // https://www.w3.org/TR/css-inline-3/#baseline-synthesis-fonts + let x_height = run.metrics.x_height.unwrap_or(run.font_size * 0.5); + -(x_height * 0.5) + } + }; + + // Compute shift offset from baseline_shift. + // Sub/Super use font-specific values from the OS/2 table when + // available, falling back to heuristics based on run metrics. + // + // TODO: In the general case, baseline shifts accumulate through + // the style tree. For example, a superscript within a superscript + // should shift by the cumulative amount. This would require the + // style resolution layer to propagate cumulative offsets. + // See: https://github.com/linebender/parley/issues/291 + let shift_offset = match baseline_shift { + BaselineShift::None => 0.0, + BaselineShift::Sub => { + run.metrics.subscript_offset.unwrap_or(run.metrics.descent) + } + BaselineShift::Super => -run + .metrics + .superscript_offset + .unwrap_or(run.metrics.ascent * 0.4), + BaselineShift::Length(v) => -v, + // Deferred to pass 2 + BaselineShift::Top | BaselineShift::Bottom => 0.0, + }; + + let offset = align_offset + shift_offset; + line_item.baseline_offset = offset; + + // Skip trailing whitespace for metrics contribution + if is_trailing_ws { + continue; + } + + // Contribute to line metrics (skip Top/Bottom) + if !is_line_relative { + let effective_ascent = (run.metrics.ascent - offset).max(0.0); + let effective_descent = (run.metrics.descent + offset).max(0.0); + line.metrics.ascent = line.metrics.ascent.max(effective_ascent); + line.metrics.descent = line.metrics.descent.max(effective_descent); + + // CSS2.1 §10.8: the inline box height = line-height, distributed + // as half-leading above ascent and below descent. The line box + // height is the union of all inline boxes, which can exceed + // nominal line_height when runs are shifted or when different + // fonts have different ascent/descent ratios. + // + // Use the run's own CSS line-height (not the line-wide + // running_line_height which includes inline box heights). + { + let half_leading = (run.metrics.line_height + - (run.metrics.ascent + run.metrics.descent)) + * 0.5; + let box_above = run.metrics.ascent + half_leading - offset; + let box_below = run.metrics.descent + half_leading + offset; + max_inline_box_above = max_inline_box_above.max(box_above); + max_inline_box_below = max_inline_box_below.max(box_below); + } + } - // Mark us as having seen non-whitespace content on this line have_metrics = true; } } } + // CSS strut (CSS2.1 §10.8.1): apply root style's font ascent/descent as + // minimum contributions, as if each line starts with a zero-width inline box + // using the root element's font and line-height. + let strut_ascent = self.layout.data.strut_ascent; + let strut_descent = self.layout.data.strut_descent; + let strut_line_height = self.layout.data.strut_line_height; + if strut_ascent > 0.0 || strut_descent > 0.0 { + line.metrics.ascent = line.metrics.ascent.max(strut_ascent); + line.metrics.descent = line.metrics.descent.max(strut_descent); + have_metrics = true; + + // Strut's inline box contribution (unshifted, so offset = 0) + let strut_half_leading = (strut_line_height - (strut_ascent + strut_descent)) * 0.5; + max_inline_box_above = max_inline_box_above.max(strut_ascent + strut_half_leading); + max_inline_box_below = max_inline_box_below.max(strut_descent + strut_half_leading); + } + + // Pass 2 is deferred until after leading computation (see below). + // Reorder the items within the line (if required). Reordering is required if the line contains // a mix of bidi levels (a mix of LTR and RTL text) let item_count = line.item_range.end - line.item_range.start; @@ -817,6 +1087,7 @@ impl<'a, B: Brush> BreakLines<'a, B> { index, bidi_level: 0, advance: 0., + baseline_offset: 0.0, is_whitespace: false, has_trailing_whitespace: false, cluster_range: cluster..cluster, @@ -827,8 +1098,24 @@ impl<'a, B: Brush> BreakLines<'a, B> { } } - line.metrics.leading = - line.metrics.line_height - (line.metrics.ascent + line.metrics.descent); + // CSS2.1 §10.8: "The line box height is the distance between the uppermost + // box top and the lowermost box bottom." §10.8.1: "line-height specifies the + // *minimal* height of line boxes." When vertical-align shifts inline boxes + // beyond the nominal line_height, expand the line box to contain them. + // + // max_inline_box_above/below now unifies BOTH text run inline boxes (with + // half-leading) and inline-block boxes (raw ascent/descent). This correctly + // computes the line box as the union of all inline-level boxes: + // - Text runs with small line-heights: half-leading shrinks the inline box, + // so text-only extent ≤ line_height (no spurious expansion). + // - Inline-blocks: contribute effective_ascent/descent directly. When an + // overflow:hidden box puts all height above baseline and text descent + // exists below, the combined extent correctly exceeds any single item. + if max_inline_box_above > f32::NEG_INFINITY { + let inline_box_extent = max_inline_box_above + max_inline_box_below; + line.metrics.line_height = line.metrics.line_height.max(inline_box_extent); + } + line.metrics.line_height = line.metrics.line_height.max(max_ibox_extent); // Whether metrics should be quantized to pixel boundaries let quantize = self.layout.data.quantize; @@ -842,6 +1129,9 @@ impl<'a, B: Brush> BreakLines<'a, B> { (line.metrics.ascent, line.metrics.descent) }; + line.metrics.leading = + line.metrics.line_height - (line.metrics.ascent + line.metrics.descent); + let (leading_above, leading_below) = if quantize { // Calculate leading using the rounded ascent and descent. let leading = line.metrics.line_height - (ascent + descent); @@ -867,6 +1157,62 @@ impl<'a, B: Brush> BreakLines<'a, B> { line.metrics.min_coord = line.metrics.baseline - ascent - leading_above.max(0.); line.metrics.max_coord = line.metrics.baseline + descent + leading_below.max(0.); + // #[cfg(feature = "std")] + // std::eprintln!( + // "[line] ascent={:.1} descent={:.1} line_height={:.1} leading={:.1} leading_above={:.1} leading_below={:.1} baseline={:.1} min={:.1} max={:.1}", + // ascent, + // descent, + // line.metrics.line_height, + // line.metrics.leading, + // leading_above, + // leading_below, + // line.metrics.baseline, + // line.metrics.min_coord, + // line.metrics.max_coord + // ); + + // Pass 2: Top/Bottom items — position relative to finalized line box. + // CSS2.1: "top" aligns box top with line box top; "bottom" aligns box + // bottom with line box bottom. The line box extends from + // (baseline - ascent - leading_above) to (baseline + descent + leading_below), + // so offsets must account for leading distribution. + for item_idx in line.item_range.clone() { + let line_item = &mut self.lines.line_items[item_idx]; + match line_item.kind { + LayoutItemKind::InlineBox => { + let item = &self.layout.data.inline_boxes[line_item.index]; + match item.baseline_shift { + BaselineShift::Top => { + // y_pos = baseline + offset - height = line_box_top + // => offset = height - ascent - leading_above + line_item.baseline_offset = item.height - ascent - leading_above; + } + BaselineShift::Bottom => { + // y_pos + height = baseline + descent + leading_below + // => offset = descent + leading_below + line_item.baseline_offset = descent + leading_below; + } + _ => {} + } + } + LayoutItemKind::TextRun => { + let (_alignment_baseline, baseline_shift) = + get_run_alignment(line_item, &self.layout.data); + let run = &self.layout.data.runs[line_item.index]; + match baseline_shift { + BaselineShift::Top => { + line_item.baseline_offset = run.metrics.ascent - ascent - leading_above; + } + BaselineShift::Bottom => { + line_item.baseline_offset = + descent + leading_below - run.metrics.descent; + } + _ => {} + } + } + } + } + self.state.committed_y += line.metrics.line_height as f64; } } @@ -998,6 +1344,7 @@ fn try_commit_line( index: item.index, bidi_level: item.bidi_level, advance: inline_box.width, + baseline_offset: 0.0, // These properties are ignored for inline boxes. So we just put a dummy value. is_whitespace: false, @@ -1050,6 +1397,7 @@ fn try_commit_line( index: item.index, bidi_level: run_data.bidi_level, advance: 0., + baseline_offset: 0.0, is_whitespace: false, has_trailing_whitespace: false, cluster_range, @@ -1117,6 +1465,20 @@ fn try_commit_line( true } +/// Get the alignment baseline and baseline shift for a text run's `LineItemData` +/// by looking up its first cluster's style. +fn get_run_alignment( + line_item: &LineItemData, + data: &LayoutData, +) -> (AlignmentBaseline, BaselineShift) { + if line_item.cluster_range.is_empty() { + return (AlignmentBaseline::Baseline, BaselineShift::None); + } + let cluster = &data.clusters[line_item.cluster_range.start]; + let style = &data.styles[cluster.style_index as usize]; + (style.alignment_baseline, style.baseline_shift) +} + /// Reorder items within line according to the bidi levels of the items fn reorder_line_items(runs: &mut [LineItemData]) { let run_count = runs.len(); diff --git a/parley/src/layout/mod.rs b/parley/src/layout/mod.rs index c332b7581..a40203c21 100644 --- a/parley/src/layout/mod.rs +++ b/parley/src/layout/mod.rs @@ -42,7 +42,7 @@ pub use crate::editing::{Cursor, Selection}; // TODO - Move the following to `style` module and submodules. use crate::style::Brush; -use crate::{LineHeight, OverflowWrap, TextWrapMode}; +use crate::{AlignmentBaseline, BaselineShift, BaselineSource, LineHeight, OverflowWrap, TextWrapMode}; #[allow(clippy::partial_pub_fields)] /// Style properties. @@ -60,6 +60,12 @@ pub struct Style { pub(crate) overflow_wrap: OverflowWrap, /// Per-cluster text-wrap-mode setting pub(crate) text_wrap_mode: TextWrapMode, + /// Which baseline to align to (CSS `alignment-baseline`) + pub(crate) alignment_baseline: AlignmentBaseline, + /// How much to shift from the alignment baseline (CSS `baseline-shift`) + pub(crate) baseline_shift: BaselineShift, + /// Which baseline set to use (CSS `baseline-source`) + pub(crate) baseline_source: BaselineSource, #[cfg(feature = "accesskit")] /// Locale if any, so we can set the corresponding AccessKit property pub(crate) locale: Option, diff --git a/parley/src/layout/run.rs b/parley/src/layout/run.rs index 944b4cb10..3e563ae82 100644 --- a/parley/src/layout/run.rs +++ b/parley/src/layout/run.rs @@ -242,4 +242,10 @@ pub struct RunMetrics { pub x_height: Option, /// Distance from the baseline to the top of capital letters. pub cap_height: Option, + /// Vertical offset for subscript positioning, from the font's OS/2 table. + /// Positive value indicates downward shift from the baseline. + pub subscript_offset: Option, + /// Vertical offset for superscript positioning, from the font's OS/2 table. + /// Positive value indicates upward shift from the baseline. + pub superscript_offset: Option, } diff --git a/parley/src/lib.rs b/parley/src/lib.rs index 713f7a07d..c2b52cefa 100644 --- a/parley/src/lib.rs +++ b/parley/src/lib.rs @@ -27,7 +27,7 @@ //! ```rust //! use parley::{ //! Alignment, AlignmentOptions, FontContext, FontWeight, InlineBox, Layout, LayoutContext, -//! LineHeight, PositionedLayoutItem, StyleProperty, +//! LineHeight, PositionedLayoutItem, StyleProperty, AlignmentBaseline, BaselineShift, BaselineSource, //! }; //! //! // Create a FontContext (font database) and LayoutContext (scratch space). @@ -47,7 +47,7 @@ //! builder.push(StyleProperty::FontWeight(FontWeight::new(600.0)), 0..4); //! //! // Add a box to be laid out inline with the text -//! builder.push_inline_box(InlineBox { id: 0, index: 5, width: 50.0, height: 50.0 }); +//! builder.push_inline_box(InlineBox { id: 0, index: 5, width: 50.0, height: 50.0, alignment_baseline: AlignmentBaseline::default(), baseline_shift: BaselineShift::default(), baseline_source: BaselineSource::default(), first_baseline: None }); //! //! // Build the builder into a Layout //! let mut layout: Layout<()> = builder.build(&TEXT); diff --git a/parley/src/resolve/mod.rs b/parley/src/resolve/mod.rs index 04803f33a..9036517cf 100644 --- a/parley/src/resolve/mod.rs +++ b/parley/src/resolve/mod.rs @@ -17,7 +17,7 @@ use super::style::{ use crate::font::FontContext; use crate::style::TextStyle; use crate::util::nearly_eq; -use crate::{LineHeight, OverflowWrap, layout}; +use crate::{AlignmentBaseline, BaselineShift, BaselineSource, LineHeight, OverflowWrap, layout}; use crate::{TextWrapMode, WordBreak}; use core::borrow::Borrow; use core::ops::Range; @@ -166,6 +166,9 @@ impl ResolveContext { StyleProperty::WordBreak(value) => WordBreak(*value), StyleProperty::OverflowWrap(value) => OverflowWrap(*value), StyleProperty::TextWrapMode(value) => TextWrapMode(*value), + StyleProperty::AlignmentBaseline(value) => AlignmentBaseline(*value), + StyleProperty::BaselineShift(value) => BaselineShift(value.scale(scale)), + StyleProperty::BaselineSource(value) => BaselineSource(*value), } } @@ -203,6 +206,9 @@ impl ResolveContext { word_break: raw_style.word_break, overflow_wrap: raw_style.overflow_wrap, text_wrap_mode: raw_style.text_wrap_mode, + alignment_baseline: raw_style.alignment_baseline, + baseline_shift: raw_style.baseline_shift.scale(scale), + baseline_source: raw_style.baseline_source, } } @@ -387,6 +393,12 @@ pub(crate) enum ResolvedProperty { OverflowWrap(OverflowWrap), /// Control over non-"emergency" line-breaking. TextWrapMode(TextWrapMode), + /// Alignment baseline of inline elements. + AlignmentBaseline(AlignmentBaseline), + /// Baseline shift of inline elements. + BaselineShift(BaselineShift), + /// Baseline source of inline elements. + BaselineSource(BaselineSource), } /// Flattened group of style properties. @@ -426,6 +438,12 @@ pub(crate) struct ResolvedStyle { pub(crate) overflow_wrap: OverflowWrap, /// Control over non-"emergency" line-breaking. pub(crate) text_wrap_mode: TextWrapMode, + /// Alignment baseline of inline elements. + pub(crate) alignment_baseline: AlignmentBaseline, + /// Baseline shift of inline elements. + pub(crate) baseline_shift: BaselineShift, + /// Baseline source of inline elements. + pub(crate) baseline_source: BaselineSource, } impl ResolvedStyle { @@ -456,6 +474,9 @@ impl ResolvedStyle { WordBreak(value) => self.word_break = value, OverflowWrap(value) => self.overflow_wrap = value, TextWrapMode(value) => self.text_wrap_mode = value, + AlignmentBaseline(value) => self.alignment_baseline = value, + BaselineShift(value) => self.baseline_shift = value, + BaselineSource(value) => self.baseline_source = value, } } @@ -485,6 +506,9 @@ impl ResolvedStyle { WordBreak(value) => self.word_break == *value, OverflowWrap(value) => self.overflow_wrap == *value, TextWrapMode(value) => self.text_wrap_mode == *value, + AlignmentBaseline(value) => self.alignment_baseline == *value, + BaselineShift(value) => self.baseline_shift.nearly_eq(*value), + BaselineSource(value) => self.baseline_source == *value, } } @@ -496,6 +520,9 @@ impl ResolvedStyle { line_height: self.line_height, overflow_wrap: self.overflow_wrap, text_wrap_mode: self.text_wrap_mode, + alignment_baseline: self.alignment_baseline, + baseline_shift: self.baseline_shift, + baseline_source: self.baseline_source, #[cfg(feature = "accesskit")] locale: self.locale, } diff --git a/parley/src/shape/mod.rs b/parley/src/shape/mod.rs index 3567d15fe..4e5012c23 100644 --- a/parley/src/shape/mod.rs +++ b/parley/src/shape/mod.rs @@ -11,6 +11,7 @@ use core::ops::RangeInclusive; use super::layout::Layout; use super::resolve::{ResolveContext, Resolved, ResolvedStyle}; use super::style::{Brush, FontFeature, FontVariation}; +use crate::{AlignmentBaseline, BaselineShift, BaselineSource}; use crate::analysis::cluster::{Char, CharCluster, Status}; use crate::analysis::{AnalysisDataSources, CharInfo}; use crate::convert::script_to_harfrust; @@ -58,6 +59,9 @@ struct Item { features: Resolved, word_spacing: f32, letter_spacing: f32, + alignment_baseline: AlignmentBaseline, + baseline_shift: BaselineShift, + baseline_source: BaselineSource, } #[allow(clippy::too_many_arguments)] @@ -104,6 +108,9 @@ pub(crate) fn shape_text<'a, B: Brush>( features: style.font_features, word_spacing: style.word_spacing, letter_spacing: style.letter_spacing, + alignment_baseline: style.alignment_baseline, + baseline_shift: style.baseline_shift, + baseline_source: style.baseline_source, }; let mut char_range = 0..0; @@ -131,6 +138,9 @@ pub(crate) fn shape_text<'a, B: Brush>( || style.font_features != item.features || !nearly_eq(style.letter_spacing, item.letter_spacing) || !nearly_eq(style.word_spacing, item.word_spacing) + || style.alignment_baseline != item.alignment_baseline + || !style.baseline_shift.nearly_eq(item.baseline_shift) + || style.baseline_source != item.baseline_source { break_run = true; } @@ -183,6 +193,9 @@ pub(crate) fn shape_text<'a, B: Brush>( item.features = style.font_features; item.word_spacing = style.word_spacing; item.letter_spacing = style.letter_spacing; + item.alignment_baseline = style.alignment_baseline; + item.baseline_shift = style.baseline_shift; + item.baseline_source = style.baseline_source; text_range.start = text_range.end; char_range.start = char_range.end; } diff --git a/parley/src/style/mod.rs b/parley/src/style/mod.rs index e5af24101..f59064f5d 100644 --- a/parley/src/style/mod.rs +++ b/parley/src/style/mod.rs @@ -20,6 +20,77 @@ pub use text_primitives::{OverflowWrap, TextWrapMode, WordBreak}; use crate::util::nearly_eq; +/// Which baseline of the element aligns with the parent's baseline. +/// +/// Corresponds to CSS `alignment-baseline` (CSS Inline Level 3). +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum AlignmentBaseline { + /// Align the element's baseline with the parent's baseline. + #[default] + Baseline, + /// Align the top of the element with the top of the parent's font. + TextTop, + /// Align the bottom of the element with the bottom of the parent's font. + TextBottom, + /// Align the midpoint of the element with the baseline plus half the x-height. + Middle, +} + +/// How much to shift from the chosen alignment baseline. +/// +/// Corresponds to CSS `baseline-shift` (CSS Inline Level 3). +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum BaselineShift { + /// No shift — stay at the alignment baseline. + #[default] + None, + /// Lower the element as appropriate for subscripts. + Sub, + /// Raise the element as appropriate for superscripts. + Super, + /// Align the top of the element with the top of the line box. + Top, + /// Align the bottom of the element with the bottom of the line box. + Bottom, + /// Shift by the given amount in layout units (positive = raise, negative = lower). + Length(f32), +} + +impl BaselineShift { + pub(crate) fn nearly_eq(self, other: Self) -> bool { + match (self, other) { + (Self::None, Self::None) + | (Self::Sub, Self::Sub) + | (Self::Super, Self::Super) + | (Self::Top, Self::Top) + | (Self::Bottom, Self::Bottom) => true, + (Self::Length(a), Self::Length(b)) => nearly_eq(a, b), + _ => false, + } + } + + pub(crate) fn scale(self, scale: f32) -> Self { + match self { + Self::Length(value) => Self::Length(value * scale), + value => value, + } + } +} + +/// Which baseline set (first or last) to use for alignment. +/// +/// Corresponds to CSS `baseline-source` (CSS Inline Level 3). +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum BaselineSource { + /// Use the first baseline set (default for most inline elements). + #[default] + Auto, + /// Use the first baseline set. + First, + /// Use the last baseline set. + Last, +} + #[derive(Debug, Clone, Copy)] pub enum WhiteSpaceCollapse { Collapse, @@ -117,6 +188,12 @@ pub enum StyleProperty<'a, B: Brush> { OverflowWrap(OverflowWrap), /// Control over non-"emergency" line-breaking. TextWrapMode(TextWrapMode), + /// Which baseline to align to (CSS `alignment-baseline`). + AlignmentBaseline(AlignmentBaseline), + /// How much to shift from the alignment baseline (CSS `baseline-shift`). + BaselineShift(BaselineShift), + /// Which baseline set to use (CSS `baseline-source`). + BaselineSource(BaselineSource), } /// Unresolved styles. @@ -168,6 +245,12 @@ pub struct TextStyle<'family, 'settings, B: Brush> { pub overflow_wrap: OverflowWrap, /// Control over non-"emergency" line-breaking. pub text_wrap_mode: TextWrapMode, + /// Which baseline to align to (CSS `alignment-baseline`). + pub alignment_baseline: AlignmentBaseline, + /// How much to shift from the alignment baseline (CSS `baseline-shift`). + pub baseline_shift: BaselineShift, + /// Which baseline set to use (CSS `baseline-source`). + pub baseline_source: BaselineSource, } impl Default for TextStyle<'static, 'static, B> { @@ -196,6 +279,9 @@ impl Default for TextStyle<'static, 'static, B> { word_break: WordBreak::default(), overflow_wrap: OverflowWrap::default(), text_wrap_mode: TextWrapMode::default(), + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), } } } @@ -241,3 +327,21 @@ impl From for StyleProperty<'_, B> { StyleProperty::LineHeight(value) } } + +impl From for StyleProperty<'_, B> { + fn from(value: AlignmentBaseline) -> Self { + StyleProperty::AlignmentBaseline(value) + } +} + +impl From for StyleProperty<'_, B> { + fn from(value: BaselineShift) -> Self { + StyleProperty::BaselineShift(value) + } +} + +impl From for StyleProperty<'_, B> { + fn from(value: BaselineSource) -> Self { + StyleProperty::BaselineSource(value) + } +} diff --git a/parley/src/tests/test_builders.rs b/parley/src/tests/test_builders.rs index 8110d3586..73093b9a8 100644 --- a/parley/src/tests/test_builders.rs +++ b/parley/src/tests/test_builders.rs @@ -13,7 +13,7 @@ use super::utils::{ColorBrush, asserts::assert_eq_layout_data}; use crate::{ FontContext, FontFamily, FontFeatures, FontVariations, Layout, LayoutContext, LineHeight, OverflowWrap, RangedBuilder, StyleProperty, StyleRunBuilder, TextStyle, TextWrapMode, - TreeBuilder, WordBreak, + TreeBuilder, AlignmentBaseline, BaselineShift, BaselineSource, WordBreak, }; // TODO: `FONT_FAMILY_LIST`, `load_fonts`, and `create_font_context` are @@ -255,6 +255,9 @@ fn create_root_style() -> TextStyle<'static, 'static, ColorBrush> { word_break: WordBreak::BreakAll, overflow_wrap: OverflowWrap::Anywhere, text_wrap_mode: TextWrapMode::Wrap, + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), } } diff --git a/parley_tests/tests/basic.rs b/parley_tests/tests/basic.rs index 2e1f425bf..2bc80ddf5 100644 --- a/parley_tests/tests/basic.rs +++ b/parley_tests/tests/basic.rs @@ -7,7 +7,7 @@ use crate::util::TestEnv; use crate::{test_name, util::ColorBrush}; use parley::{ Alignment, AlignmentOptions, BreakReason, ContentWidths, FontFamily, InlineBox, Layout, - LineHeight, PositionedLayoutItem, StyleProperty, TextStyle, WhiteSpaceCollapse, + LineHeight, PositionedLayoutItem, StyleProperty, TextStyle, AlignmentBaseline, BaselineShift, BaselineSource, WhiteSpaceCollapse, }; use peniko::color::{AlphaColor, Srgb, palette}; use peniko::kurbo::Size; @@ -69,6 +69,10 @@ fn placing_inboxes() { index: position, width: 10.0, height: 10.0, + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), + first_baseline: None, }); let mut layout = builder.build(text); layout.break_all_lines(None); @@ -89,6 +93,10 @@ fn only_inboxes_wrap() { index: 0, width: 10.0, height: 10.0, + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), + first_baseline: None, }); } let mut layout = builder.build(text); @@ -110,18 +118,30 @@ fn full_width_inbox() { index: 1, width: 10., height: 10.0, + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), + first_baseline: None, }); builder.push_inline_box(InlineBox { id: 1, index: 1, width, height: 10.0, + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), + first_baseline: None, }); builder.push_inline_box(InlineBox { id: 2, index: 2, width, height: 10.0, + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), + first_baseline: None, }); let mut layout = builder.build(text); layout.break_all_lines(Some(100.)); @@ -140,6 +160,10 @@ fn inbox_separated_by_whitespace() { index: 0, width: 10., height: 10.0, + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), + first_baseline: None, }); builder.push_text(" "); builder.push_inline_box(InlineBox { @@ -147,6 +171,10 @@ fn inbox_separated_by_whitespace() { index: 1, width: 10.0, height: 10.0, + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), + first_baseline: None, }); builder.push_text(" "); builder.push_inline_box(InlineBox { @@ -154,6 +182,10 @@ fn inbox_separated_by_whitespace() { index: 2, width: 10.0, height: 10.0, + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), + first_baseline: None, }); builder.push_text(" "); builder.push_inline_box(InlineBox { @@ -161,6 +193,10 @@ fn inbox_separated_by_whitespace() { index: 3, width: 10.0, height: 10.0, + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), + first_baseline: None, }); let (mut layout, _text) = builder.build(); layout.break_all_lines(Some(100.)); @@ -442,6 +478,10 @@ fn inbox_content_width() { index: 3, width: 100.0, height: 10.0, + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), + first_baseline: None, }); let mut layout = builder.build(text); let ContentWidths { @@ -462,6 +502,10 @@ fn inbox_content_width() { index: 2, width: 10.0, height: 10.0, + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), + first_baseline: None, }); let mut layout = builder.build(text); let ContentWidths { diff --git a/parley_tests/tests/line_break.rs b/parley_tests/tests/line_break.rs index cdf3b498f..ae98b51f9 100644 --- a/parley_tests/tests/line_break.rs +++ b/parley_tests/tests/line_break.rs @@ -9,7 +9,9 @@ use crate::test_name; use crate::util::TestEnv; use parley::style::FontFamily; -use parley::{Alignment, AlignmentOptions, InlineBox, PositionedLayoutItem, StyleProperty}; +use parley::{ + Alignment, AlignmentOptions, InlineBox, PositionedLayoutItem, StyleProperty, AlignmentBaseline, BaselineShift, BaselineSource, +}; #[test] fn break_by_length_basic() { @@ -98,6 +100,10 @@ fn break_by_length_with_inline_box() { index: 1, // After 'A' width: 10.0, height: 10.0, + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), + first_baseline: None, }); let mut layout = builder.build(text); @@ -124,6 +130,10 @@ fn break_by_length_multiple_inline_boxes() { index: 0, // All at the start width: 10.0, height: 10.0, + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), + first_baseline: None, }); } let mut layout = builder.build(text); diff --git a/parley_tests/tests/lines.rs b/parley_tests/tests/lines.rs index 1330338d3..0a029c9df 100644 --- a/parley_tests/tests/lines.rs +++ b/parley_tests/tests/lines.rs @@ -10,7 +10,7 @@ use crate::test_name; use crate::util::{ColorBrush, TestEnv}; use parley::{ Affinity, Alignment, AlignmentOptions, BoundingBox, Brush, Cursor, InlineBox, Layout, - LineHeight, Selection, StyleProperty, + LineHeight, Selection, StyleProperty, AlignmentBaseline, BaselineShift, BaselineSource, }; use peniko::kurbo::Size; @@ -111,12 +111,20 @@ fn build_layout>>( index: 40, width: 50.0, height: 5.0, + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), + first_baseline: None, }); builder.push_inline_box(InlineBox { id: 1, index: 51, width: 50.0, height: 3.0, + alignment_baseline: AlignmentBaseline::default(), + baseline_shift: BaselineShift::default(), + baseline_source: BaselineSource::default(), + first_baseline: None, }); let mut layout = builder.build(TEXT); diff --git a/parley_tests/tests/mod.rs b/parley_tests/tests/mod.rs index 3772bca71..65b23cd77 100644 --- a/parley_tests/tests/mod.rs +++ b/parley_tests/tests/mod.rs @@ -31,6 +31,7 @@ mod line_break; mod lines; mod styles; mod text_indent; +mod vertical_align; mod wrap; #[macro_use] mod util; diff --git a/parley_tests/tests/vertical_align.rs b/parley_tests/tests/vertical_align.rs new file mode 100644 index 000000000..7aa7b1874 --- /dev/null +++ b/parley_tests/tests/vertical_align.rs @@ -0,0 +1,1025 @@ +// Copyright 2026 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Tests for CSS `vertical-align` support (CSS Inline Level 3 decomposition). + +use crate::util::TestEnv; +use crate::{test_name, util::ColorBrush}; +use parley::{ + Alignment, AlignmentBaseline, AlignmentOptions, BaselineShift, BaselineSource, InlineBox, + Layout, PositionedLayoutItem, StyleProperty, +}; + +/// Helper: build a single-line layout with the given text and inline boxes, +/// break and align it, and return the layout. +fn build_layout_with_boxes( + env: &mut TestEnv, + text: &str, + boxes: Vec, + max_width: Option, +) -> Layout { + let mut builder = env.ranged_builder(text); + for b in boxes { + builder.push_inline_box(b); + } + let mut layout = builder.build(text); + layout.break_all_lines(max_width); + layout.align(max_width, Alignment::Start, AlignmentOptions::default()); + layout +} + +/// Helper: build a layout with mixed vertical-align text runs using the tree builder. +/// Each segment specifies alignment_baseline and baseline_shift independently. +fn build_layout_with_valign_text( + env: &mut TestEnv, + segments: &[(&str, AlignmentBaseline, BaselineShift)], +) -> Layout { + let mut builder = env.tree_builder(); + for (text, ab, bs) in segments { + builder.push_style_modification_span(&[ + StyleProperty::AlignmentBaseline(*ab), + StyleProperty::BaselineShift(*bs), + ]); + builder.push_text(text); + builder.pop_style_span(); + } + let (mut layout, _text) = builder.build(); + layout.break_all_lines(None); + layout.align(None, Alignment::Start, AlignmentOptions::default()); + layout +} + +/// Shorthand: default alignment (baseline, no shift). +const DEFAULT: (AlignmentBaseline, BaselineShift) = + (AlignmentBaseline::Baseline, BaselineShift::None); + +/// Collect all positioned items from the first line. +fn first_line_items(layout: &Layout) -> Vec> { + layout + .lines() + .next() + .unwrap() + .items() + .collect::>() +} + +/// Get the baseline of the first glyph run on the first line. +fn first_glyph_run_baseline(layout: &Layout) -> f32 { + for item in first_line_items(layout) { + if let PositionedLayoutItem::GlyphRun(gr) = item { + return gr.baseline(); + } + } + panic!("no glyph run found"); +} + +/// Helper: create a default InlineBox with overrides for alignment. +fn make_box( + id: u64, + index: usize, + width: f32, + height: f32, + alignment_baseline: AlignmentBaseline, + baseline_shift: BaselineShift, +) -> InlineBox { + InlineBox { + id, + index, + width, + height, + alignment_baseline, + baseline_shift, + baseline_source: BaselineSource::default(), + first_baseline: None, + } +} + +// --------------------------------------------------------------------------- +// Tests: Baseline (default) — should produce identical output to no vertical-align +// --------------------------------------------------------------------------- + +#[test] +fn vertical_align_baseline_is_default() { + let mut env = TestEnv::new(test_name!(), None); + + // Layout with no vertical-align set + let text = "Hello world"; + let builder_default = env.ranged_builder(text); + let mut layout_default = builder_default.build(text); + layout_default.break_all_lines(None); + layout_default.align(None, Alignment::Start, AlignmentOptions::default()); + + // Layout with explicit Baseline + no shift + let layout_explicit = build_layout_with_valign_text( + &mut env, + &[("Hello world", DEFAULT.0, DEFAULT.1)], + ); + + let baseline_default = first_glyph_run_baseline(&layout_default); + let baseline_explicit = first_glyph_run_baseline(&layout_explicit); + assert!( + (baseline_default - baseline_explicit).abs() < 0.01, + "Baseline vertical-align should match default: {baseline_default} vs {baseline_explicit}" + ); +} + +// --------------------------------------------------------------------------- +// Tests: InlineBox vertical-align variants +// --------------------------------------------------------------------------- + +#[test] +fn vertical_align_inline_box_baseline() { + let mut env = TestEnv::new(test_name!(), None); + + let layout = build_layout_with_boxes( + &mut env, + "Hello", + vec![make_box(0, 5, 20.0, 30.0, AlignmentBaseline::Baseline, BaselineShift::None)], + None, + ); + + let items = first_line_items(&layout); + let line = layout.lines().next().unwrap(); + let baseline = line.metrics().baseline; + + for item in &items { + if let PositionedLayoutItem::InlineBox(ib) = item { + let expected_y = baseline - 30.0; + assert!( + (ib.y - expected_y).abs() < 0.01, + "Baseline box y should be {expected_y}, got {}", + ib.y + ); + } + } +} + +#[test] +fn vertical_align_inline_box_length_raises() { + let mut env = TestEnv::new(test_name!(), None); + + let raise = 10.0; + let layout = build_layout_with_boxes( + &mut env, + "Hello", + vec![make_box(0, 5, 20.0, 30.0, AlignmentBaseline::Baseline, BaselineShift::Length(raise))], + None, + ); + + let baseline = layout.lines().next().unwrap().metrics().baseline; + + for item in first_line_items(&layout) { + if let PositionedLayoutItem::InlineBox(ib) = item { + let expected_y = baseline - raise - 30.0; + assert!( + (ib.y - expected_y).abs() < 0.01, + "Length({raise}) box y should be {expected_y}, got {}", + ib.y + ); + } + } +} + +#[test] +fn vertical_align_inline_box_length_lowers() { + let mut env = TestEnv::new(test_name!(), None); + + let lower = -8.0; + let layout = build_layout_with_boxes( + &mut env, + "Hello", + vec![make_box(0, 5, 20.0, 30.0, AlignmentBaseline::Baseline, BaselineShift::Length(lower))], + None, + ); + + let baseline = layout.lines().next().unwrap().metrics().baseline; + + for item in first_line_items(&layout) { + if let PositionedLayoutItem::InlineBox(ib) = item { + let expected_y = baseline - lower - 30.0; + assert!( + (ib.y - expected_y).abs() < 0.01, + "Length({lower}) box y should be {expected_y}, got {}", + ib.y + ); + } + } +} + +#[test] +fn vertical_align_inline_box_top_bottom() { + let mut env = TestEnv::new(test_name!(), None); + + let box_height = 10.0; + + let layout = build_layout_with_boxes( + &mut env, + "Hello", + vec![ + make_box(0, 5, 20.0, box_height, AlignmentBaseline::Baseline, BaselineShift::Top), + make_box(1, 5, 20.0, box_height, AlignmentBaseline::Baseline, BaselineShift::Bottom), + ], + None, + ); + + let line = layout.lines().next().unwrap(); + let metrics = line.metrics(); + let baseline = metrics.baseline; + let ascent = metrics.ascent; + let descent = metrics.descent; + + let mut top_y = None; + let mut bottom_y = None; + + for item in first_line_items(&layout) { + if let PositionedLayoutItem::InlineBox(ib) = item { + match ib.id { + 0 => top_y = Some(ib.y), + 1 => bottom_y = Some(ib.y), + _ => {} + } + } + } + + let top_y = top_y.expect("Top box not found"); + let bottom_y = bottom_y.expect("Bottom box not found"); + + let expected_top_y = baseline - ascent; + assert!( + (top_y - expected_top_y).abs() < 0.5, + "Top box top edge should be at line top: expected {expected_top_y}, got {top_y}" + ); + + let expected_bottom_y = baseline + descent - box_height; + assert!( + (bottom_y - expected_bottom_y).abs() < 0.5, + "Bottom box bottom edge should be at line bottom: expected {expected_bottom_y}, got {bottom_y}" + ); +} + +// --------------------------------------------------------------------------- +// Tests: Text run baseline-shift variants +// --------------------------------------------------------------------------- + +#[test] +fn vertical_align_text_sub_lowers_baseline() { + let mut env = TestEnv::new(test_name!(), None); + + let layout = build_layout_with_valign_text( + &mut env, + &[ + ("Normal", DEFAULT.0, DEFAULT.1), + ("sub", AlignmentBaseline::Baseline, BaselineShift::Sub), + ], + ); + + let items = first_line_items(&layout); + let mut baselines: Vec<(String, f32)> = Vec::new(); + + for item in &items { + if let PositionedLayoutItem::GlyphRun(gr) = item { + let text: String = gr.run().clusters().map(|c| c.source_char()).collect(); + baselines.push((text, gr.baseline())); + } + } + + assert!(baselines.len() >= 2, "Expected at least 2 glyph runs, got {}", baselines.len()); + + let normal_baseline = baselines.iter().find(|(t, _)| t.starts_with('N')).unwrap().1; + let sub_baseline = baselines.iter().find(|(t, _)| t.starts_with('s')).unwrap().1; + + assert!( + sub_baseline > normal_baseline, + "Sub text baseline ({sub_baseline}) should be lower (larger y) than normal ({normal_baseline})" + ); +} + +#[test] +fn vertical_align_text_super_raises_baseline() { + let mut env = TestEnv::new(test_name!(), None); + + let layout = build_layout_with_valign_text( + &mut env, + &[ + ("Normal", DEFAULT.0, DEFAULT.1), + ("sup", AlignmentBaseline::Baseline, BaselineShift::Super), + ], + ); + + let items = first_line_items(&layout); + let mut baselines: Vec<(String, f32)> = Vec::new(); + + for item in &items { + if let PositionedLayoutItem::GlyphRun(gr) = item { + let text: String = gr.run().clusters().map(|c| c.source_char()).collect(); + baselines.push((text, gr.baseline())); + } + } + + let normal_baseline = baselines.iter().find(|(t, _)| t.starts_with('N')).unwrap().1; + let super_baseline = baselines.iter().find(|(t, _)| t.starts_with('s')).unwrap().1; + + assert!( + super_baseline < normal_baseline, + "Super text baseline ({super_baseline}) should be higher (smaller y) than normal ({normal_baseline})" + ); +} + +#[test] +fn vertical_align_text_length_positive_raises() { + let mut env = TestEnv::new(test_name!(), None); + + let raise = 5.0; + let layout = build_layout_with_valign_text( + &mut env, + &[ + ("Normal", DEFAULT.0, DEFAULT.1), + ("raised", AlignmentBaseline::Baseline, BaselineShift::Length(raise)), + ], + ); + + let items = first_line_items(&layout); + let mut baselines: Vec<(String, f32)> = Vec::new(); + + for item in &items { + if let PositionedLayoutItem::GlyphRun(gr) = item { + let text: String = gr.run().clusters().map(|c| c.source_char()).collect(); + baselines.push((text, gr.baseline())); + } + } + + let normal_baseline = baselines.iter().find(|(t, _)| t.starts_with('N')).unwrap().1; + let raised_baseline = baselines.iter().find(|(t, _)| t.starts_with('r')).unwrap().1; + + let expected_diff = raise; + let actual_diff = normal_baseline - raised_baseline; + assert!( + (actual_diff - expected_diff).abs() < 0.01, + "Length({raise}) should raise baseline by {expected_diff}, actual diff: {actual_diff}" + ); +} + +#[test] +fn vertical_align_text_length_negative_lowers() { + let mut env = TestEnv::new(test_name!(), None); + + let lower = -5.0; + let layout = build_layout_with_valign_text( + &mut env, + &[ + ("Normal", DEFAULT.0, DEFAULT.1), + ("lowered", AlignmentBaseline::Baseline, BaselineShift::Length(lower)), + ], + ); + + let items = first_line_items(&layout); + let mut baselines: Vec<(String, f32)> = Vec::new(); + + for item in &items { + if let PositionedLayoutItem::GlyphRun(gr) = item { + let text: String = gr.run().clusters().map(|c| c.source_char()).collect(); + baselines.push((text, gr.baseline())); + } + } + + let normal_baseline = baselines.iter().find(|(t, _)| t.starts_with('N')).unwrap().1; + let lowered_baseline = baselines.iter().find(|(t, _)| t.starts_with('l')).unwrap().1; + + assert!( + lowered_baseline > normal_baseline, + "Length({lower}) should lower baseline: lowered={lowered_baseline}, normal={normal_baseline}" + ); + let actual_diff = lowered_baseline - normal_baseline; + let expected_diff = -lower; + assert!( + (actual_diff - expected_diff).abs() < 0.01, + "Expected diff {expected_diff}, got {actual_diff}" + ); +} + +// --------------------------------------------------------------------------- +// Tests: Line metrics expansion +// --------------------------------------------------------------------------- + +#[test] +fn vertical_align_sub_expands_line_descent() { + let mut env = TestEnv::new(test_name!(), None); + + let layout_normal = build_layout_with_valign_text( + &mut env, + &[("Hello world", DEFAULT.0, DEFAULT.1)], + ); + let metrics_normal = *layout_normal.lines().next().unwrap().metrics(); + + let layout_sub = build_layout_with_valign_text( + &mut env, + &[ + ("Hello ", DEFAULT.0, DEFAULT.1), + ("world", AlignmentBaseline::Baseline, BaselineShift::Sub), + ], + ); + let metrics_sub = *layout_sub.lines().next().unwrap().metrics(); + + assert!( + metrics_sub.descent >= metrics_normal.descent - 0.01, + "Sub text should not reduce descent: sub={}, normal={}", + metrics_sub.descent, + metrics_normal.descent + ); +} + +#[test] +fn vertical_align_super_expands_line_ascent() { + let mut env = TestEnv::new(test_name!(), None); + + let layout_normal = build_layout_with_valign_text( + &mut env, + &[("Hello world", DEFAULT.0, DEFAULT.1)], + ); + let metrics_normal = *layout_normal.lines().next().unwrap().metrics(); + + let layout_super = build_layout_with_valign_text( + &mut env, + &[ + ("Hello ", DEFAULT.0, DEFAULT.1), + ("world", AlignmentBaseline::Baseline, BaselineShift::Super), + ], + ); + let metrics_super = *layout_super.lines().next().unwrap().metrics(); + + assert!( + metrics_super.ascent >= metrics_normal.ascent - 0.01, + "Super text should not reduce ascent: super={}, normal={}", + metrics_super.ascent, + metrics_normal.ascent + ); +} + +#[test] +fn vertical_align_large_length_expands_line_visual_extent() { + let mut env = TestEnv::new(test_name!(), None); + + let layout_normal = build_layout_with_valign_text( + &mut env, + &[("Hello", DEFAULT.0, DEFAULT.1)], + ); + let metrics_normal = *layout_normal.lines().next().unwrap().metrics(); + let extent_normal = metrics_normal.max_coord - metrics_normal.min_coord; + + let layout_raised = build_layout_with_valign_text( + &mut env, + &[ + ("Hello ", DEFAULT.0, DEFAULT.1), + ("UP", AlignmentBaseline::Baseline, BaselineShift::Length(50.0)), + ], + ); + let metrics_raised = *layout_raised.lines().next().unwrap().metrics(); + let extent_raised = metrics_raised.max_coord - metrics_raised.min_coord; + + assert!( + extent_raised > extent_normal, + "Large Length(50) should expand visual extent: raised={extent_raised}, normal={extent_normal}" + ); + + assert!( + metrics_raised.ascent > metrics_normal.ascent + 10.0, + "Raised text should increase ascent: raised={}, normal={}", + metrics_raised.ascent, + metrics_normal.ascent + ); +} + +// --------------------------------------------------------------------------- +// Tests: Multiple vertical-align values on same line +// --------------------------------------------------------------------------- + +#[test] +fn vertical_align_mixed_runs_all_different() { + let mut env = TestEnv::new(test_name!(), None); + + let layout = build_layout_with_valign_text( + &mut env, + &[ + ("A", AlignmentBaseline::Baseline, BaselineShift::Super), + ("B", DEFAULT.0, DEFAULT.1), + ("C", AlignmentBaseline::Baseline, BaselineShift::Sub), + ], + ); + + let items = first_line_items(&layout); + let mut baselines: Vec<(char, f32)> = Vec::new(); + + for item in &items { + if let PositionedLayoutItem::GlyphRun(gr) = item { + let ch = gr.run().clusters().next().unwrap().source_char(); + baselines.push((ch, gr.baseline())); + } + } + + let a_baseline = baselines.iter().find(|(c, _)| *c == 'A').unwrap().1; + let b_baseline = baselines.iter().find(|(c, _)| *c == 'B').unwrap().1; + let c_baseline = baselines.iter().find(|(c, _)| *c == 'C').unwrap().1; + + assert!( + a_baseline < b_baseline, + "Super baseline ({a_baseline}) should be above Baseline ({b_baseline})" + ); + assert!( + b_baseline < c_baseline, + "Baseline ({b_baseline}) should be above Sub ({c_baseline})" + ); +} + +// --------------------------------------------------------------------------- +// Tests: Inline box alignment_baseline variants +// --------------------------------------------------------------------------- + +#[test] +fn vertical_align_inline_box_middle() { + let mut env = TestEnv::new(test_name!(), None); + + let box_height = 20.0; + let layout = build_layout_with_boxes( + &mut env, + "Hello", + vec![make_box(0, 5, 20.0, box_height, AlignmentBaseline::Middle, BaselineShift::None)], + None, + ); + + let line = layout.lines().next().unwrap(); + let baseline = line.metrics().baseline; + + // Get x-height from the first text run on the line. + // CSS spec fallback: font_size * 0.5 + let x_height = line + .runs() + .next() + .map(|r| { + r.metrics() + .x_height + .unwrap_or(r.font_size() * 0.5) + }) + .unwrap_or(0.0); + + for item in first_line_items(&layout) { + if let PositionedLayoutItem::InlineBox(ib) = item { + // CSS middle: center the box at baseline - x_height/2 + // offset = (height - x_height) / 2 + // y = baseline + offset - height + let offset = (box_height - x_height) / 2.0; + let expected_y = baseline + offset - box_height; + assert!( + (ib.y - expected_y).abs() < 0.5, + "Middle box y should be {expected_y}, got {} (x_height={x_height})", + ib.y + ); + } + } +} + +#[test] +fn vertical_align_text_top_bottom_align_with_line_edges() { + let mut env = TestEnv::new(test_name!(), None); + + let layout = build_layout_with_valign_text( + &mut env, + &[ + ("Normal", DEFAULT.0, DEFAULT.1), + ("T", AlignmentBaseline::Baseline, BaselineShift::Top), + ("B", AlignmentBaseline::Baseline, BaselineShift::Bottom), + ], + ); + + let items = first_line_items(&layout); + let metrics = *layout.lines().next().unwrap().metrics(); + let mut baselines: Vec<(char, f32)> = Vec::new(); + + for item in &items { + if let PositionedLayoutItem::GlyphRun(gr) = item { + let ch = gr.run().clusters().next().unwrap().source_char(); + baselines.push((ch, gr.baseline())); + } + } + + let normal_baseline = baselines.iter().find(|(c, _)| *c == 'N').unwrap().1; + let top_baseline = baselines.iter().find(|(c, _)| *c == 'T').unwrap().1; + let bottom_baseline = baselines.iter().find(|(c, _)| *c == 'B').unwrap().1; + + let line_top = metrics.baseline - metrics.ascent; + let line_bottom = metrics.baseline + metrics.descent; + + assert!( + top_baseline >= line_top && top_baseline <= line_bottom, + "Top baseline {top_baseline} should be within line box [{line_top}, {line_bottom}]" + ); + assert!( + bottom_baseline >= line_top && bottom_baseline <= line_bottom, + "Bottom baseline {bottom_baseline} should be within line box [{line_top}, {line_bottom}]" + ); + + // With same font, top/bottom/baseline should all be at the same position + assert!( + (top_baseline - normal_baseline).abs() < 0.5, + "Same-font Top should match Baseline: {top_baseline} vs {normal_baseline}" + ); + assert!( + (bottom_baseline - normal_baseline).abs() < 0.5, + "Same-font Bottom should match Baseline: {bottom_baseline} vs {normal_baseline}" + ); +} + +// --------------------------------------------------------------------------- +// Tests: Combined alignment_baseline + baseline_shift (the key CSS3 feature) +// --------------------------------------------------------------------------- + +#[test] +fn vertical_align_combined_text_top_with_shift() { + let mut env = TestEnv::new(test_name!(), None); + + // This is the case that was impossible with the single-enum model: + // alignment-baseline: text-top; baseline-shift: 2px + let layout = build_layout_with_valign_text( + &mut env, + &[ + ("Normal", DEFAULT.0, DEFAULT.1), + ("shifted", AlignmentBaseline::TextTop, BaselineShift::Length(2.0)), + ], + ); + + let items = first_line_items(&layout); + let mut baselines: Vec<(String, f32)> = Vec::new(); + + for item in &items { + if let PositionedLayoutItem::GlyphRun(gr) = item { + let text: String = gr.run().clusters().map(|c| c.source_char()).collect(); + baselines.push((text, gr.baseline())); + } + } + + // With same font, TextTop aligns the run top with the line top (offset = 0 for same font). + // Then Length(2.0) raises it by 2 additional units. + // So the shifted run's baseline should be 2 units higher than the TextTop-only position. + let normal_baseline = baselines.iter().find(|(t, _)| t.starts_with('N')).unwrap().1; + let shifted_baseline = baselines.iter().find(|(t, _)| t.starts_with('s')).unwrap().1; + + // With same font, text-top alone = baseline, so combined = baseline - 2.0 + let expected_diff = 2.0; + let actual_diff = normal_baseline - shifted_baseline; + assert!( + (actual_diff - expected_diff).abs() < 0.5, + "TextTop + Length(2) should raise by ~{expected_diff}: actual diff {actual_diff}" + ); +} + +// --------------------------------------------------------------------------- +// Tests: first_baseline on InlineBox +// --------------------------------------------------------------------------- + +#[test] +fn vertical_align_inline_box_first_baseline() { + let mut env = TestEnv::new(test_name!(), None); + + let box_height = 30.0; + let first_baseline_val = 10.0; // baseline is 10px from the top of the box + + // Box with first_baseline set — its internal baseline should align with the line baseline + let layout = build_layout_with_boxes( + &mut env, + "Hello", + vec![InlineBox { + id: 0, + index: 5, + width: 20.0, + height: box_height, + alignment_baseline: AlignmentBaseline::Baseline, + baseline_shift: BaselineShift::None, + baseline_source: BaselineSource::default(), + first_baseline: Some(first_baseline_val), + }], + None, + ); + + let line = layout.lines().next().unwrap(); + let baseline = line.metrics().baseline; + + for item in first_line_items(&layout) { + if let PositionedLayoutItem::InlineBox(ib) = item { + // With first_baseline = 10, box top should be at baseline - 10 + // y = baseline + offset - height, where offset = -(height - first_baseline) + let expected_y = baseline - first_baseline_val; + assert!( + (ib.y - expected_y).abs() < 0.5, + "first_baseline box y should be {expected_y}, got {} (box top at baseline - {first_baseline_val})", + ib.y + ); + } + } +} + +#[test] +fn vertical_align_inline_box_first_baseline_none_matches_default() { + let mut env = TestEnv::new(test_name!(), None); + + // Box without first_baseline (None) — bottom at baseline, same as original behavior + let layout_none = build_layout_with_boxes( + &mut env, + "Hello", + vec![make_box(0, 5, 20.0, 30.0, AlignmentBaseline::Baseline, BaselineShift::None)], + None, + ); + + // Box with first_baseline = height — baseline at bottom, same as None + let layout_full = build_layout_with_boxes( + &mut env, + "Hello", + vec![InlineBox { + id: 0, + index: 5, + width: 20.0, + height: 30.0, + alignment_baseline: AlignmentBaseline::Baseline, + baseline_shift: BaselineShift::None, + baseline_source: BaselineSource::default(), + first_baseline: Some(30.0), // baseline at bottom = same as None + }], + None, + ); + + let y_none = first_line_items(&layout_none) + .iter() + .find_map(|item| { + if let PositionedLayoutItem::InlineBox(ib) = item { Some(ib.y) } else { None } + }) + .unwrap(); + + let y_full = first_line_items(&layout_full) + .iter() + .find_map(|item| { + if let PositionedLayoutItem::InlineBox(ib) = item { Some(ib.y) } else { None } + }) + .unwrap(); + + assert!( + (y_none - y_full).abs() < 0.01, + "first_baseline=height should match None: none={y_none}, full={y_full}" + ); +} + +// --------------------------------------------------------------------------- +// Tests: Font-metric Sub/Super offsets +// --------------------------------------------------------------------------- + +#[test] +fn vertical_align_sub_super_use_font_metrics() { + let mut env = TestEnv::new(test_name!(), None); + + // Build a layout with Sub and Super text to verify they produce different offsets + // from each other and from the baseline. The exact values depend on the font's + // OS/2 table, but we can verify the structural properties. + let layout = build_layout_with_valign_text( + &mut env, + &[ + ("Normal", DEFAULT.0, DEFAULT.1), + ("sub", AlignmentBaseline::Baseline, BaselineShift::Sub), + ("sup", AlignmentBaseline::Baseline, BaselineShift::Super), + ], + ); + + let items = first_line_items(&layout); + let mut baselines: Vec<(String, f32)> = Vec::new(); + for item in &items { + if let PositionedLayoutItem::GlyphRun(gr) = item { + let text: String = gr.run().clusters().map(|c| c.source_char()).collect(); + + // Verify the run actually has font metrics available + let metrics = gr.run().metrics(); + // subscript_offset and superscript_offset should be populated from the font + // (Roboto has an OS/2 table, so these should be Some) + if text.starts_with('s') { + assert!( + metrics.subscript_offset.is_some() || metrics.superscript_offset.is_some(), + "Test font should provide OS/2 subscript/superscript metrics" + ); + } + + baselines.push((text, gr.baseline())); + } + } + + let normal = baselines.iter().find(|(t, _)| t.starts_with('N')).unwrap().1; + let sub = baselines.iter().find(|(t, _)| t == "sub").unwrap().1; + let sup = baselines.iter().find(|(t, _)| t == "sup").unwrap().1; + + // Structural properties: super is above normal, sub is below normal + assert!(sup < normal, "Super should be above normal: {sup} vs {normal}"); + assert!(sub > normal, "Sub should be below normal: {sub} vs {normal}"); + + // The offsets should be non-trivial (at least 1px with 16px default font size) + assert!( + normal - sup > 1.0, + "Super offset should be significant: {}", normal - sup + ); + assert!( + sub - normal > 1.0, + "Sub offset should be significant: {}", sub - normal + ); +} + +// --------------------------------------------------------------------------- +// Tests: Line box height expansion for shifted inline boxes +// --------------------------------------------------------------------------- + +#[test] +fn vertical_align_inline_box_shift_expands_line_height() { + let mut env = TestEnv::new(test_name!(), None); + + // Baseline: unshifted inline-block + let layout_normal = build_layout_with_boxes( + &mut env, + "Hello", + vec![make_box( + 0, 5, 20.0, 20.0, + AlignmentBaseline::Baseline, + BaselineShift::None, + )], + None, + ); + let height_normal = layout_normal.height(); + + // Shifted: raise the box by 20px — it should push the line box taller + let layout_shifted = build_layout_with_boxes( + &mut env, + "Hello", + vec![make_box( + 0, 5, 20.0, 20.0, + AlignmentBaseline::Baseline, + BaselineShift::Length(20.0), + )], + None, + ); + let height_shifted = layout_shifted.height(); + + assert!( + height_shifted > height_normal, + "Shifting an inline box upward should expand layout height: shifted={height_shifted}, normal={height_normal}" + ); + + // The expansion should be approximately the shift amount + let expansion = height_shifted - height_normal; + assert!( + expansion > 15.0, + "Expected ~20px expansion from Length(20) shift, got {expansion}" + ); +} + +#[test] +fn vertical_align_inline_box_downward_shift_expands_descent() { + let mut env = TestEnv::new(test_name!(), None); + + let layout_normal = build_layout_with_boxes( + &mut env, + "Hello", + vec![make_box( + 0, 5, 20.0, 20.0, + AlignmentBaseline::Baseline, + BaselineShift::None, + )], + None, + ); + let metrics_normal = *layout_normal.lines().next().unwrap().metrics(); + + // Shift the box downward — should expand descent + let layout_shifted = build_layout_with_boxes( + &mut env, + "Hello", + vec![make_box( + 0, 5, 20.0, 20.0, + AlignmentBaseline::Baseline, + BaselineShift::Length(-15.0), + )], + None, + ); + let metrics_shifted = *layout_shifted.lines().next().unwrap().metrics(); + + assert!( + metrics_shifted.descent > metrics_normal.descent, + "Downward shift should expand descent: shifted={}, normal={}", + metrics_shifted.descent, + metrics_normal.descent + ); +} + +// --------------------------------------------------------------------------- +// Tests: Line box height expansion for shifted text runs (CSS inline box model) +// --------------------------------------------------------------------------- + +#[test] +fn vertical_align_text_shift_expands_layout_height() { + let mut env = TestEnv::new(test_name!(), None); + + let layout_normal = build_layout_with_valign_text( + &mut env, + &[("Hello world", DEFAULT.0, DEFAULT.1)], + ); + let height_normal = layout_normal.height(); + + // Large upward shift should expand the line box beyond nominal line_height + let layout_shifted = build_layout_with_valign_text( + &mut env, + &[ + ("Hello ", DEFAULT.0, DEFAULT.1), + ("UP", AlignmentBaseline::Baseline, BaselineShift::Length(30.0)), + ], + ); + let height_shifted = layout_shifted.height(); + + assert!( + height_shifted > height_normal, + "Large text shift should expand layout height: shifted={height_shifted}, normal={height_normal}" + ); +} + +#[test] +fn vertical_align_text_shift_line_height_is_minimum() { + let mut env = TestEnv::new(test_name!(), None); + + // With no shift, line_height should equal the nominal line-height + let layout_normal = build_layout_with_valign_text( + &mut env, + &[("Hello", DEFAULT.0, DEFAULT.1)], + ); + let line_height_normal = layout_normal.lines().next().unwrap().metrics().line_height; + + // With a shift, the line_height metric should be >= nominal + let layout_shifted = build_layout_with_valign_text( + &mut env, + &[ + ("Hello ", DEFAULT.0, DEFAULT.1), + ("UP", AlignmentBaseline::Baseline, BaselineShift::Length(40.0)), + ], + ); + let line_height_shifted = layout_shifted.lines().next().unwrap().metrics().line_height; + + assert!( + line_height_shifted >= line_height_normal, + "CSS spec: line-height is a minimum — shifted line_height ({line_height_shifted}) should be >= nominal ({line_height_normal})" + ); + assert!( + line_height_shifted > line_height_normal + 10.0, + "Length(40) shift should expand line_height significantly: shifted={line_height_shifted}, normal={line_height_normal}" + ); +} + +// --------------------------------------------------------------------------- +// Tests: Unshifted content does not over-expand +// --------------------------------------------------------------------------- + +#[test] +fn vertical_align_no_shift_line_height_matches_nominal() { + let mut env = TestEnv::new(test_name!(), None); + + // With no shifts, layout height should equal nominal line_height (within tolerance) + let layout = build_layout_with_valign_text( + &mut env, + &[("Hello world", DEFAULT.0, DEFAULT.1)], + ); + + let line_height = layout.lines().next().unwrap().metrics().line_height; + let layout_height = layout.height(); + + // Single line: layout height should equal line_height + assert!( + (layout_height - line_height).abs() < 0.01, + "Single-line layout height ({layout_height}) should match line_height ({line_height})" + ); +} + +#[test] +fn vertical_align_inline_box_no_shift_preserves_height() { + let mut env = TestEnv::new(test_name!(), None); + + // A small unshifted inline box should not expand the line beyond what text already requires + let layout_text_only = build_layout_with_valign_text( + &mut env, + &[("Hello world", DEFAULT.0, DEFAULT.1)], + ); + let height_text_only = layout_text_only.height(); + + let layout_with_box = build_layout_with_boxes( + &mut env, + "Hello", + vec![make_box( + 0, 5, 20.0, 5.0, // Small box, shorter than text + AlignmentBaseline::Baseline, + BaselineShift::None, + )], + None, + ); + let height_with_box = layout_with_box.height(); + + // A 5px tall box at baseline shouldn't expand the line if text is already taller + assert!( + (height_with_box - height_text_only).abs() < 2.0, + "Small unshifted box should not significantly expand height: with_box={height_with_box}, text_only={height_text_only}" + ); +}