From d5555f80faac85607bd5d880c918fe0237a61338 Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Sat, 18 Jul 2026 17:43:58 +0800 Subject: [PATCH] Improve Opera text and gradient parity Add GPOS pair adjustment shaping, preserve unitless/fixed line-height across later font-size changes, and clip shifted no-repeat radial-gradient layers to their positioned image rect. Track the remaining Opera Chromium parity gap in isu issue 406. Refs isu issue 281 Refs isu issue 406 --- .isu/issues.json | 16 +- crates/render/src/gpu.rs | 103 +++- crates/render/src/lib.rs | 10 +- crates/style/src/computed.rs | 119 ++++- crates/text/src/font/mod.rs | 90 +++- crates/text/src/font/tables/gpos.rs | 772 ++++++++++++++++++++++++++++ crates/text/src/font/tables/mod.rs | 1 + 7 files changed, 1088 insertions(+), 23 deletions(-) create mode 100644 crates/text/src/font/tables/gpos.rs diff --git a/.isu/issues.json b/.isu/issues.json index 8d8736e..a3da90a 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -1,5 +1,5 @@ { - "next_id": 406, + "next_id": 407, "issues": [ { "id": 1, @@ -4962,6 +4962,20 @@ "author": "piefev", "state": "open", "created_at": "2026-07-18T09:10:52Z" + }, + { + "id": 406, + "repo": "we", + "title": "Opera parity remains blocked after GPOS, line-height, and radial-gradient clipping", + "body": "Parent: isu issue 281. Follow-up after adding OpenType GPOS pair-positioning support, preserving computed unitless/fixed line-height across later font-size changes, and clipping shifted no-repeat radial-gradient layers to their positioned image rect.\\n\\nThe Opera real-web scenario still cannot remove its xfail.\\n\\nRepro:\\n cargo run -p we-e2e -- --scenario crates/e2e/scenarios/real-web/opera.com.we --out-dir crates/e2e/artifacts\\n\\nCurrent result after this pass:\\n- desktop L101: 70.17% match (366520/1228500 px differ, tol=4, max_diff=0.1000%)\\n- mobile L113: 69.81% match (99366/329160 px differ, tol=4, max_diff=0.1000%)\\n- DOM and interactivity assertions pass; console captures only expected offline GTM fetch misses.\\n\\nArtifacts:\\n- crates/e2e/artifacts/real-web/opera.com/desktop.png\\n- crates/e2e/artifacts/real-web/opera.com/desktop.png.diff.png\\n- crates/e2e/artifacts/real-web/opera.com/desktop_dom.txt\\n- crates/e2e/artifacts/real-web/opera.com/desktop_interaction_dom.txt\\n- crates/e2e/artifacts/real-web/opera.com/mobile.png\\n- crates/e2e/artifacts/real-web/opera.com/mobile.png.diff.png\\n- crates/e2e/artifacts/real-web/opera.com/mobile_dom.txt\\n- crates/e2e/artifacts/real-web/opera.com/mobile_interaction_dom.txt\\n\\nRemaining visible blockers are still concentrated in broad Chromium text/raster parity and the hero/cookie panel area. The standards fixes from this pass improved desktop materially from the prior 64.68% baseline, but the strict screenshot threshold is still far away. Keep crates/e2e/scenarios/real-web/opera.com.we xfail until both Chromium screenshot assertions pass.", + "labels": [ + "real-web", + "text" + ], + "assigned": [], + "author": "piefev", + "state": "open", + "created_at": "2026-07-18T09:37:46Z" } ] } diff --git a/crates/render/src/gpu.rs b/crates/render/src/gpu.rs index c7ea2fb..d8f8500 100644 --- a/crates/render/src/gpu.rs +++ b/crates/render/src/gpu.rs @@ -22,7 +22,7 @@ use we_platform::metal::{ MTL_PIXEL_FORMAT_BGRA8_UNORM, MTL_PRIMITIVE_TYPE_TRIANGLE, TEXTURE_MODE_COLOR, TEXTURE_MODE_MASK, TEXTURE_MODE_SDF, TEXTURE_MODE_SOLID, }; -use we_style::computed::{LengthOrAuto, ObjectFit}; +use we_style::computed::{BackgroundRepeat, LengthOrAuto, ObjectFit}; use we_text::font::{Font, FontRegistry}; use crate::atlas::{GlyphAtlas, TexturedQuad}; @@ -708,6 +708,7 @@ impl GpuRenderer { height, position_x, position_y, + repeat, gradient, } => { let tex_key = TextureKey::Solid; @@ -720,6 +721,7 @@ impl GpuRenderer { &mut batch_vertices, (*x + coord_offset.0, *y + coord_offset.1, *width, *height), (*position_x, *position_y), + *repeat, gradient, ); } @@ -1244,6 +1246,7 @@ fn push_radial_gradient_quad( out: &mut Vec, rect: (f32, f32, f32, f32), position: (LengthOrAuto, LengthOrAuto), + repeat: BackgroundRepeat, gradient: &RadialGradient, ) { let (x, y, w, h) = rect; @@ -1261,13 +1264,30 @@ fn push_radial_gradient_quad( let offset_y = crate::background_position_offset(position_y, 0.0, h); let image_x = x + offset_x; let image_y = y + offset_y; + let image_right = image_x + w; + let image_bottom = image_y + h; + + let (paint_x, paint_y, paint_w, paint_h) = match repeat { + BackgroundRepeat::Repeat => (x, y, w, h), + BackgroundRepeat::NoRepeat => { + let left = x.max(image_x); + let top = y.max(image_y); + let right = (x + w).min(image_right); + let bottom = (y + h).min(image_bottom); + if right <= left || bottom <= top { + return; + } + (left, top, right - left, bottom - top) + } + }; + let center_x = image_x + w * 0.5; let center_y = image_y + h * 0.5; let radius = [ (image_x, image_y), - (image_x + w, image_y), - (image_x + w, image_y + h), - (image_x, image_y + h), + (image_right, image_y), + (image_right, image_bottom), + (image_x, image_bottom), ] .iter() .map(|(px, py)| (*px - center_x).hypot(*py - center_y)) @@ -1276,8 +1296,8 @@ fn push_radial_gradient_quad( return; } - let cols = ((w / 18.0).ceil() as usize).clamp(8, 96); - let rows = ((h / 18.0).ceil() as usize).clamp(8, 96); + let cols = ((paint_w / 18.0).ceil() as usize).clamp(8, 96); + let rows = ((paint_h / 18.0).ceil() as usize).clamp(8, 96); let vertex = |px: f32, py: f32| { let t = ((px - center_x).hypot(py - center_y) / radius).clamp(0.0, 1.0); Vertex { @@ -1290,11 +1310,11 @@ fn push_radial_gradient_quad( }; for row in 0..rows { - let y0 = y + h * row as f32 / rows as f32; - let y1 = y + h * (row + 1) as f32 / rows as f32; + let y0 = paint_y + paint_h * row as f32 / rows as f32; + let y1 = paint_y + paint_h * (row + 1) as f32 / rows as f32; for col in 0..cols { - let x0 = x + w * col as f32 / cols as f32; - let x1 = x + w * (col + 1) as f32 / cols as f32; + let x0 = paint_x + paint_w * col as f32 / cols as f32; + let x1 = paint_x + paint_w * (col + 1) as f32 / cols as f32; let top_left = vertex(x0, y0); let top_right = vertex(x1, y0); let bottom_right = vertex(x1, y1); @@ -2192,6 +2212,69 @@ mod tests { } } + fn test_radial_gradient() -> RadialGradient { + RadialGradient { + stops: vec![ + GradientStop { + color: Color::new(87, 41, 255, 59), + position: Some(0.0), + }, + GradientStop { + color: Color::new(0, 0, 0, 0), + position: Some(100.0), + }, + ], + } + } + + #[test] + fn shifted_no_repeat_radial_gradient_clips_to_positioned_image() { + let mut verts = Vec::new(); + push_radial_gradient_quad( + &mut verts, + (0.0, 0.0, 100.0, 50.0), + (LengthOrAuto::Length(60.0), LengthOrAuto::Length(0.0)), + BackgroundRepeat::NoRepeat, + &test_radial_gradient(), + ); + + assert!(!verts.is_empty()); + let min_x = verts + .iter() + .map(|v| v.position[0]) + .fold(f32::INFINITY, f32::min); + let max_x = verts + .iter() + .map(|v| v.position[0]) + .fold(f32::NEG_INFINITY, f32::max); + assert!((min_x - 60.0).abs() < 0.001); + assert!((max_x - 100.0).abs() < 0.001); + } + + #[test] + fn shifted_repeating_radial_gradient_keeps_full_paint_area() { + let mut verts = Vec::new(); + push_radial_gradient_quad( + &mut verts, + (0.0, 0.0, 100.0, 50.0), + (LengthOrAuto::Length(60.0), LengthOrAuto::Length(0.0)), + BackgroundRepeat::Repeat, + &test_radial_gradient(), + ); + + assert!(!verts.is_empty()); + let min_x = verts + .iter() + .map(|v| v.position[0]) + .fold(f32::INFINITY, f32::min); + let max_x = verts + .iter() + .map(|v| v.position[0]) + .fold(f32::NEG_INFINITY, f32::max); + assert!((min_x - 0.0).abs() < 0.001); + assert!((max_x - 100.0).abs() < 0.001); + } + #[test] fn textured_quad_vertex_count() { let quad = TexturedQuad { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 7387d80..4f9da2b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -86,6 +86,7 @@ pub enum PaintCommand { height: f32, position_x: LengthOrAuto, position_y: LengthOrAuto, + repeat: BackgroundRepeat, gradient: RadialGradient, }, /// Draw a text fragment at a position with styling. @@ -956,6 +957,7 @@ fn paint_background(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: index, layout_box.background_position_y, ), + repeat: layout_box.background_repeat, gradient: gradient.clone(), }); } @@ -3223,9 +3225,10 @@ div { height, position_x, position_y, + repeat, gradient, .. - } => Some((*width, *height, *position_x, *position_y, gradient)), + } => Some((*width, *height, *position_x, *position_y, *repeat, gradient)), _ => None, }) .expect("radial-gradient should produce a gradient paint command"); @@ -3233,8 +3236,9 @@ div { assert!((gradient.1 - 50.0).abs() < 0.5); assert_eq!(gradient.2, LengthOrAuto::Length(480.0)); assert_eq!(gradient.3, LengthOrAuto::Length(-36.0)); - assert_eq!(gradient.4.stops[0].color, Color::new(87, 41, 255, 59)); - assert_eq!(gradient.4.stops[1].color, Color::new(0, 0, 0, 0)); + assert_eq!(gradient.4, BackgroundRepeat::Repeat); + assert_eq!(gradient.5.stops[0].color, Color::new(87, 41, 255, 59)); + assert_eq!(gradient.5.stops[1].color, Color::new(0, 0, 0, 0)); } #[test] diff --git a/crates/style/src/computed.rs b/crates/style/src/computed.rs index 75e318b..2f01387 100644 --- a/crates/style/src/computed.rs +++ b/crates/style/src/computed.rs @@ -805,6 +805,14 @@ pub struct TextShadow { pub color: Color, } +/// How the computed `line-height` should respond to later `font-size` changes. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum LineHeightKind { + Normal, + Number(f32), + Length, +} + // --------------------------------------------------------------------------- // ComputedStyle // --------------------------------------------------------------------------- @@ -881,6 +889,7 @@ pub struct ComputedStyle { pub text_shadows: Vec, pub letter_spacing: f32, pub line_height: f32, + pub line_height_kind: LineHeightKind, pub white_space: WhiteSpace, // Background @@ -1044,6 +1053,7 @@ impl Default for ComputedStyle { text_shadows: Vec::new(), letter_spacing: 0.0, line_height: 19.2, // 1.2 * 16 + line_height_kind: LineHeightKind::Normal, white_space: WhiteSpace::Normal, background_color: Color::new(0, 0, 0, 0), // transparent @@ -2190,6 +2200,33 @@ fn font_family_to_css_text(value: &CssValue) -> Option { } } +fn recompute_line_height_for_font_size(style: &mut ComputedStyle) { + match style.line_height_kind { + LineHeightKind::Normal => { + style.line_height = style.font_size * 1.2; + } + LineHeightKind::Number(n) => { + style.line_height = n * style.font_size; + } + LineHeightKind::Length => {} + } +} + +fn inherit_line_height(style: &mut ComputedStyle, parent: &ComputedStyle) { + style.line_height_kind = parent.line_height_kind; + match parent.line_height_kind { + LineHeightKind::Normal => { + style.line_height = style.font_size * 1.2; + } + LineHeightKind::Number(n) => { + style.line_height = n * style.font_size; + } + LineHeightKind::Length => { + style.line_height = parent.line_height; + } + } +} + fn apply_property( style: &mut ComputedStyle, property: &str, @@ -2502,8 +2539,7 @@ fn apply_property( } _ => {} } - // Update line-height when font-size changes - style.line_height = style.font_size * 1.2; + recompute_line_height_for_font_size(style); } // Font-weight (inherited) @@ -2618,16 +2654,21 @@ fn apply_property( // Line-height (inherited) "line-height" => match value { CssValue::Keyword(k) if k == "normal" => { + style.line_height_kind = LineHeightKind::Normal; style.line_height = style.font_size * 1.2; } CssValue::Number(n) => { - style.line_height = *n as f32 * style.font_size; + let number = *n as f32; + style.line_height_kind = LineHeightKind::Number(number); + style.line_height = number * style.font_size; } CssValue::Length(n, unit) => { + style.line_height_kind = LineHeightKind::Length; style.line_height = resolve_length_unit(*n, *unit, style.font_size, root_font_size, viewport); } CssValue::Percentage(p) => { + style.line_height_kind = LineHeightKind::Length; style.line_height = (*p / 100.0) as f32 * style.font_size; } CssValue::Math(expr) => { @@ -2638,6 +2679,7 @@ fn apply_property( style.font_size, viewport, ) { + style.line_height_kind = LineHeightKind::Length; style.line_height = px; } } @@ -3675,7 +3717,7 @@ fn inherit_property(style: &mut ComputedStyle, property: &str, parent: &Computed "color" => style.color = parent.color, "font-size" => { style.font_size = parent.font_size; - style.line_height = style.font_size * 1.2; + recompute_line_height_for_font_size(style); } "font-weight" => style.font_weight = parent.font_weight, "font-style" => style.font_style = parent.font_style, @@ -3685,7 +3727,7 @@ fn inherit_property(style: &mut ComputedStyle, property: &str, parent: &Computed "text-transform" => style.text_transform = parent.text_transform, "text-shadow" => style.text_shadows = parent.text_shadows.clone(), "letter-spacing" => style.letter_spacing = parent.letter_spacing, - "line-height" => style.line_height = parent.line_height, + "line-height" => inherit_line_height(style, parent), "visibility" => style.visibility = parent.visibility, "white-space" => style.white_space = parent.white_space, "cursor" => style.cursor = parent.cursor, @@ -3801,7 +3843,7 @@ fn reset_property_to_initial(style: &mut ComputedStyle, property: &str) { "color" => style.color = initial.color, "font-size" => { style.font_size = initial.font_size; - style.line_height = initial.line_height; + recompute_line_height_for_font_size(style); } "font-weight" => style.font_weight = initial.font_weight, "font-style" => style.font_style = initial.font_style, @@ -3811,7 +3853,10 @@ fn reset_property_to_initial(style: &mut ComputedStyle, property: &str) { "text-transform" => style.text_transform = initial.text_transform, "text-shadow" => style.text_shadows = initial.text_shadows, "letter-spacing" => style.letter_spacing = initial.letter_spacing, - "line-height" => style.line_height = initial.line_height, + "line-height" => { + style.line_height_kind = LineHeightKind::Normal; + recompute_line_height_for_font_size(style); + } "white-space" => style.white_space = initial.white_space, "background-color" => style.background_color = initial.background_color, "background-image" => { @@ -4422,6 +4467,7 @@ fn compute_style_for_element_with_inputs( text_shadows: parent_style.text_shadows.clone(), letter_spacing: parent_style.letter_spacing, line_height: parent_style.line_height, + line_height_kind: parent_style.line_height_kind, white_space: parent_style.white_space, visibility: parent_style.visibility, cursor: parent_style.cursor, @@ -6885,6 +6931,65 @@ mod tests { assert_eq!(div_node.style.line_height, 20.0); } + #[test] + fn unitless_line_height_tracks_later_font_size() { + let (mut doc, _, _, body) = make_doc_with_body(); + let h1 = doc.create_element("h1"); + let text = doc.create_text("Title"); + doc.set_attribute(h1, "class", "big"); + doc.append_child(body, h1); + doc.append_child(h1, text); + + let ss = Parser::parse("h1 { line-height: 1; } .big { font-size: 56px; }"); + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); + let h1_node = &styled.children[0].children[0]; + + assert_eq!(h1_node.style.font_size, 56.0); + assert_eq!(h1_node.style.line_height_kind, LineHeightKind::Number(1.0)); + assert_eq!(h1_node.style.line_height, 56.0); + } + + #[test] + fn fixed_line_height_survives_later_font_size() { + let (mut doc, _, _, body) = make_doc_with_body(); + let h1 = doc.create_element("h1"); + let text = doc.create_text("Title"); + doc.set_attribute(h1, "class", "big"); + doc.append_child(body, h1); + doc.append_child(h1, text); + + let ss = Parser::parse("h1 { line-height: 20px; } .big { font-size: 56px; }"); + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); + let h1_node = &styled.children[0].children[0]; + + assert_eq!(h1_node.style.font_size, 56.0); + assert_eq!(h1_node.style.line_height_kind, LineHeightKind::Length); + assert_eq!(h1_node.style.line_height, 20.0); + } + + #[test] + fn inherited_unitless_line_height_uses_child_font_size() { + let (mut doc, _, _, body) = make_doc_with_body(); + let div = doc.create_element("div"); + let span = doc.create_element("span"); + let text = doc.create_text("Title"); + doc.set_attribute(span, "class", "big"); + doc.append_child(body, div); + doc.append_child(div, span); + doc.append_child(span, text); + + let ss = Parser::parse("div { line-height: 1.5; } .big { font-size: 20px; }"); + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); + let span_node = &styled.children[0].children[0].children[0]; + + assert_eq!(span_node.style.font_size, 20.0); + assert_eq!( + span_node.style.line_height_kind, + LineHeightKind::Number(1.5) + ); + assert_eq!(span_node.style.line_height, 30.0); + } + fn grid_doc(css: &str, body_html: &str) -> StyledNode { let html = format!( "{}", diff --git a/crates/text/src/font/mod.rs b/crates/text/src/font/mod.rs index fe0715b..7ae690d 100644 --- a/crates/text/src/font/mod.rs +++ b/crates/text/src/font/mod.rs @@ -19,6 +19,7 @@ pub use rasterizer::GlyphBitmap; pub use registry::{FontEntry, FontRegistry, WebFontEntry, WebFontState}; pub use tables::cmap::CmapTable; pub use tables::glyf::{Contour, GlyphOutline, Point}; +pub use tables::gpos::GposTable; pub use tables::head::HeadTable; pub use tables::hhea::HheaTable; pub use tables::hmtx::HmtxTable; @@ -299,6 +300,16 @@ impl Font { } } + /// Parse the `GPOS` table, if present. + /// + /// Returns an empty `GposTable` if the font has no GPOS table. + pub fn gpos(&self) -> Result { + match self.table_data(b"GPOS") { + Some(data) => GposTable::parse(data), + None => Ok(GposTable::empty()), + } + } + /// Look up the raw kerning value for a pair of glyph IDs (in font units). /// /// Returns 0 if the font has no kern table or the pair has no adjustment. @@ -388,7 +399,14 @@ impl Font { x_advance, }); - cursor_x += x_advance + kern_adjustment.x as f32 * sf.scale; + let gpos_adjustment = match resolved.get(i + 1) { + Some(&(next_idx, next_gid)) if next_idx == font_idx => { + sf.gpos_table.adjustment(gid, next_gid) + } + _ => 0, + }; + + cursor_x += x_advance + (kern_adjustment.x as f32 + gpos_adjustment as f32) * sf.scale; if kern_adjustment.reset_cross_stream { cursor_y = 0.0; } else { @@ -563,13 +581,14 @@ impl Font { } /// Pre-parsed per-font shaping state: cmap for coverage probing, hmtx for -/// advances, kern for adjustments, and the em scale at the requested size. +/// advances, kerning adjustments, and the em scale at the requested size. struct ShapeFont { id: u32, scale: f32, cmap: CmapTable, hmtx: HmtxTable, kern_table: KernTable, + gpos_table: GposTable, } impl ShapeFont { @@ -585,6 +604,7 @@ impl ShapeFont { cmap: font.cmap().ok()?, hmtx: font.hmtx().ok()?, kern_table: font.kern().unwrap_or_else(|_| KernTable::empty()), + gpos_table: font.gpos().unwrap_or_else(|_| GposTable::empty()), }) } @@ -703,6 +723,10 @@ mod tests { } fn synthetic_font(kern: Option>) -> Font { + synthetic_font_with_tables(kern, None) + } + + fn synthetic_font_with_tables(kern: Option>, gpos: Option>) -> Font { let mut head = vec![0; 54]; write_u16(&mut head, 0, 1); write_u16(&mut head, 18, 1000); @@ -750,6 +774,9 @@ mod tests { if let Some(kern) = kern { tables.push((*b"kern", kern)); } + if let Some(gpos) = gpos { + tables.push((*b"GPOS", gpos)); + } let num_tables = tables.len(); let directory_len = 12 + num_tables * 16; @@ -804,6 +831,51 @@ mod tests { data } + fn gpos_pairpos_format1(value: i16) -> Vec { + let feature_list_offset = 10usize; + let lookup_list_offset = 24usize; + let lookup_offset = 28usize; + let pair_pos_offset = 36usize; + + let mut data = Vec::new(); + push_u16(&mut data, 1); // GPOS major + push_u16(&mut data, 0); // GPOS minor + push_u16(&mut data, 0); // scriptListOffset + push_u16(&mut data, feature_list_offset as u16); + push_u16(&mut data, lookup_list_offset as u16); + + push_u16(&mut data, 1); // featureCount + data.extend_from_slice(b"kern"); + push_u16(&mut data, 8); // feature table offset + push_u16(&mut data, 0); // featureParams + push_u16(&mut data, 1); // lookupIndexCount + push_u16(&mut data, 0); // lookup index 0 + + push_u16(&mut data, 1); // lookupCount + push_u16(&mut data, (lookup_offset - lookup_list_offset) as u16); + + push_u16(&mut data, 2); // lookupType PairPos + push_u16(&mut data, 0); // lookupFlag + push_u16(&mut data, 1); // subTableCount + push_u16(&mut data, (pair_pos_offset - lookup_offset) as u16); + + push_u16(&mut data, 1); // PairPos format 1 + push_u16(&mut data, 12); // coverageOffset + push_u16(&mut data, 0x0004); // valueFormat1: xAdvance + push_u16(&mut data, 0); // valueFormat2 + push_u16(&mut data, 1); // pairSetCount + push_u16(&mut data, 18); // pairSetOffset + + push_u16(&mut data, 1); // coverage format 1 + push_u16(&mut data, 1); // glyphCount + push_u16(&mut data, 1); // left glyph for 'A' + + push_u16(&mut data, 1); // pairValueCount + push_u16(&mut data, 2); // right glyph for 'B' + push_i16(&mut data, value); + data + } + #[test] fn parse_table_directory() { let font = test_font(); @@ -1282,6 +1354,20 @@ mod tests { assert_eq!(shaped[1].y_offset, 0.0); } + #[test] + fn shape_text_applies_gpos_pair_adjustments() { + let font = synthetic_font_with_tables(None, Some(gpos_pairpos_format1(-100))); + let shaped = font.shape_text("AB", 10.0); + + assert_eq!(shaped.len(), 2); + assert_eq!(shaped[0].glyph_id, 1); + assert_eq!(shaped[1].glyph_id, 2); + assert_eq!(shaped[0].x_offset, 0.0); + assert_eq!(shaped[0].x_advance, 6.0); + assert_eq!(shaped[1].x_offset, 5.0); + assert_eq!(shaped[1].y_offset, 0.0); + } + #[test] fn shape_text_applies_cross_stream_kern_pairs() { let font = synthetic_font(Some(kern_format0(0x0005, 100))); diff --git a/crates/text/src/font/tables/gpos.rs b/crates/text/src/font/tables/gpos.rs new file mode 100644 index 0000000..d38e338 --- /dev/null +++ b/crates/text/src/font/tables/gpos.rs @@ -0,0 +1,772 @@ +//! `GPOS` — OpenType glyph positioning. +//! +//! This parser implements the horizontal pair-adjustment subset used for +//! kerning in modern web fonts. It supports lookup type 2 Pair Adjustment +//! Positioning formats 1 and 2, plus type 9 extension lookups that wrap type 2. + +use crate::font::parse::Reader; +use crate::font::FontError; + +/// Parsed GPOS pair-positioning data. +#[derive(Debug)] +pub struct GposTable { + subtables: Vec, +} + +#[derive(Debug)] +enum PairPosSubtable { + Format1 { + coverage: Coverage, + pair_sets: Vec>, + }, + Format2 { + coverage: Coverage, + class1: ClassDef, + class2: ClassDef, + class1_count: usize, + class2_count: usize, + x_advances: Vec, + }, +} + +#[derive(Debug, Clone, Copy)] +struct PairValue { + second: u16, + x_advance: i16, +} + +#[derive(Debug)] +enum Coverage { + Format1(Vec), + Format2(Vec), +} + +#[derive(Debug)] +struct CoverageRange { + start: u16, + end: u16, + start_index: u16, +} + +#[derive(Debug)] +enum ClassDef { + Format1 { start: u16, classes: Vec }, + Format2(Vec), +} + +#[derive(Debug)] +struct ClassRange { + start: u16, + end: u16, + class: u16, +} + +#[derive(Debug, Clone, Copy, Default)] +struct ValueRecord { + x_advance: i16, +} + +impl GposTable { + /// Create an empty GPOS table. + pub fn empty() -> GposTable { + GposTable { + subtables: Vec::new(), + } + } + + /// Parse a GPOS table. + pub fn parse(data: &[u8]) -> Result { + let r = Reader::new(data); + if r.len() < 10 { + return Err(FontError::MalformedTable("GPOS")); + } + + let major = r.u16(0)?; + if major != 1 { + return Ok(GposTable::empty()); + } + + let feature_list_offset = r.u16(6)? as usize; + let lookup_list_offset = r.u16(8)? as usize; + let selected = parse_kern_feature_lookup_indices(data, feature_list_offset) + .unwrap_or_else(|_| Vec::new()); + + let mut subtables = Vec::new(); + parse_lookup_list(data, lookup_list_offset, &selected, &mut subtables)?; + + Ok(GposTable { subtables }) + } + + /// Return summed horizontal pair adjustment for two glyph IDs in font units. + pub(crate) fn adjustment(&self, left: u16, right: u16) -> i16 { + self.subtables.iter().fold(0i16, |acc, subtable| { + acc.saturating_add(subtable.adjustment(left, right)) + }) + } + + /// Number of parsed pair-positioning subtables. + pub fn num_subtables(&self) -> usize { + self.subtables.len() + } +} + +fn parse_kern_feature_lookup_indices( + data: &[u8], + feature_list_offset: usize, +) -> Result, FontError> { + if feature_list_offset == 0 { + return Ok(Vec::new()); + } + + let r = Reader::new(data); + if feature_list_offset + 2 > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + + let feature_count = r.u16(feature_list_offset)? as usize; + let mut indices = Vec::new(); + + for i in 0..feature_count { + let record = feature_list_offset + 2 + i * 6; + if record + 6 > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + + let tag = r.tag(record)?; + if &tag != b"kern" && &tag != b"dist" { + continue; + } + + let feature_offset = checked_offset(feature_list_offset, r.u16(record + 4)? as usize)?; + if feature_offset + 4 > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + + let lookup_count = r.u16(feature_offset + 2)? as usize; + for j in 0..lookup_count { + let offset = feature_offset + 4 + j * 2; + if offset + 2 > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + indices.push(r.u16(offset)? as usize); + } + } + + indices.sort_unstable(); + indices.dedup(); + Ok(indices) +} + +fn parse_lookup_list( + data: &[u8], + lookup_list_offset: usize, + selected: &[usize], + subtables: &mut Vec, +) -> Result<(), FontError> { + if lookup_list_offset == 0 { + return Ok(()); + } + + let r = Reader::new(data); + if lookup_list_offset + 2 > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + + let lookup_count = r.u16(lookup_list_offset)? as usize; + for i in 0..lookup_count { + if !selected.is_empty() && selected.binary_search(&i).is_err() { + continue; + } + + let offset_pos = lookup_list_offset + 2 + i * 2; + if offset_pos + 2 > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + + let lookup_offset = checked_offset(lookup_list_offset, r.u16(offset_pos)? as usize)?; + parse_lookup(data, lookup_offset, subtables)?; + } + + Ok(()) +} + +fn parse_lookup( + data: &[u8], + lookup_offset: usize, + subtables: &mut Vec, +) -> Result<(), FontError> { + let r = Reader::new(data); + if lookup_offset + 6 > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + + let lookup_type = r.u16(lookup_offset)?; + let subtable_count = r.u16(lookup_offset + 4)? as usize; + for i in 0..subtable_count { + let offset_pos = lookup_offset + 6 + i * 2; + if offset_pos + 2 > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + + let subtable_offset = checked_offset(lookup_offset, r.u16(offset_pos)? as usize)?; + match lookup_type { + 2 => { + if let Some(subtable) = parse_pair_pos(data, subtable_offset)? { + subtables.push(subtable); + } + } + 9 => parse_extension_lookup(data, subtable_offset, subtables)?, + _ => {} + } + } + + Ok(()) +} + +fn parse_extension_lookup( + data: &[u8], + subtable_offset: usize, + subtables: &mut Vec, +) -> Result<(), FontError> { + let r = Reader::new(data); + if subtable_offset + 8 > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + + let format = r.u16(subtable_offset)?; + let extension_lookup_type = r.u16(subtable_offset + 2)?; + if format != 1 || extension_lookup_type != 2 { + return Ok(()); + } + + let extension_offset = r.u32(subtable_offset + 4)? as usize; + let pair_offset = checked_offset(subtable_offset, extension_offset)?; + if let Some(subtable) = parse_pair_pos(data, pair_offset)? { + subtables.push(subtable); + } + + Ok(()) +} + +fn parse_pair_pos( + data: &[u8], + subtable_offset: usize, +) -> Result, FontError> { + let r = Reader::new(data); + if subtable_offset + 2 > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + + match r.u16(subtable_offset)? { + 1 => parse_pair_pos_format1(data, subtable_offset).map(Some), + 2 => parse_pair_pos_format2(data, subtable_offset).map(Some), + _ => Ok(None), + } +} + +fn parse_pair_pos_format1( + data: &[u8], + subtable_offset: usize, +) -> Result { + let r = Reader::new(data); + if subtable_offset + 10 > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + + let coverage_offset = checked_offset(subtable_offset, r.u16(subtable_offset + 2)? as usize)?; + let value_format1 = r.u16(subtable_offset + 4)?; + let value_format2 = r.u16(subtable_offset + 6)?; + let pair_set_count = r.u16(subtable_offset + 8)? as usize; + let coverage = Coverage::parse(data, coverage_offset)?; + + let mut pair_sets = Vec::with_capacity(pair_set_count); + for i in 0..pair_set_count { + let offset_pos = subtable_offset + 10 + i * 2; + if offset_pos + 2 > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + + let pair_set_offset = checked_offset(subtable_offset, r.u16(offset_pos)? as usize)?; + pair_sets.push(parse_pair_set( + data, + pair_set_offset, + value_format1, + value_format2, + )?); + } + + Ok(PairPosSubtable::Format1 { + coverage, + pair_sets, + }) +} + +fn parse_pair_set( + data: &[u8], + pair_set_offset: usize, + value_format1: u16, + value_format2: u16, +) -> Result, FontError> { + let r = Reader::new(data); + if pair_set_offset + 2 > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + + let pair_value_count = r.u16(pair_set_offset)? as usize; + let value1_size = value_record_size(value_format1); + let value2_size = value_record_size(value_format2); + let record_size = 2 + value1_size + value2_size; + let mut values = Vec::with_capacity(pair_value_count); + + for i in 0..pair_value_count { + let record = pair_set_offset + 2 + i * record_size; + if record + record_size > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + + let second = r.u16(record)?; + let value1 = read_value_record(data, record + 2, value_format1)?; + values.push(PairValue { + second, + x_advance: value1.x_advance, + }); + } + + values.sort_by_key(|value| value.second); + Ok(values) +} + +fn parse_pair_pos_format2( + data: &[u8], + subtable_offset: usize, +) -> Result { + let r = Reader::new(data); + if subtable_offset + 16 > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + + let coverage_offset = checked_offset(subtable_offset, r.u16(subtable_offset + 2)? as usize)?; + let value_format1 = r.u16(subtable_offset + 4)?; + let value_format2 = r.u16(subtable_offset + 6)?; + let class1_offset = checked_offset(subtable_offset, r.u16(subtable_offset + 8)? as usize)?; + let class2_offset = checked_offset(subtable_offset, r.u16(subtable_offset + 10)? as usize)?; + let class1_count = r.u16(subtable_offset + 12)? as usize; + let class2_count = r.u16(subtable_offset + 14)? as usize; + + let coverage = Coverage::parse(data, coverage_offset)?; + let class1 = ClassDef::parse(data, class1_offset)?; + let class2 = ClassDef::parse(data, class2_offset)?; + + let value1_size = value_record_size(value_format1); + let value2_size = value_record_size(value_format2); + let record_size = value1_size + value2_size; + let pair_count = class1_count + .checked_mul(class2_count) + .ok_or(FontError::MalformedTable("GPOS"))?; + let total_size = pair_count + .checked_mul(record_size) + .ok_or(FontError::MalformedTable("GPOS"))?; + let records_offset = subtable_offset + 16; + if records_offset + total_size > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + + let mut x_advances = Vec::with_capacity(pair_count); + for i in 0..pair_count { + let record = records_offset + i * record_size; + let value1 = read_value_record(data, record, value_format1)?; + x_advances.push(value1.x_advance); + } + + Ok(PairPosSubtable::Format2 { + coverage, + class1, + class2, + class1_count, + class2_count, + x_advances, + }) +} + +fn read_value_record( + data: &[u8], + offset: usize, + value_format: u16, +) -> Result { + let r = Reader::new(data); + let mut cursor = offset; + let mut record = ValueRecord::default(); + + for bit in 0..8 { + if value_format & (1 << bit) == 0 { + continue; + } + + if cursor + 2 > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + + if bit == 2 { + record.x_advance = r.i16(cursor)?; + } + cursor += 2; + } + + Ok(record) +} + +fn value_record_size(value_format: u16) -> usize { + ((value_format & 0x00ff).count_ones() as usize) * 2 +} + +fn checked_offset(base: usize, relative: usize) -> Result { + base.checked_add(relative) + .ok_or(FontError::MalformedTable("GPOS")) +} + +impl PairPosSubtable { + fn adjustment(&self, left: u16, right: u16) -> i16 { + match self { + PairPosSubtable::Format1 { + coverage, + pair_sets, + } => { + let Some(index) = coverage.index(left) else { + return 0; + }; + let Some(pair_set) = pair_sets.get(index) else { + return 0; + }; + pair_set + .binary_search_by(|pair| pair.second.cmp(&right)) + .ok() + .map(|idx| pair_set[idx].x_advance) + .unwrap_or(0) + } + PairPosSubtable::Format2 { + coverage, + class1, + class2, + class1_count, + class2_count, + x_advances, + } => { + if coverage.index(left).is_none() { + return 0; + } + + let c1 = class1.class(left) as usize; + let c2 = class2.class(right) as usize; + if c1 >= *class1_count || c2 >= *class2_count { + return 0; + } + + let index = c1 * *class2_count + c2; + x_advances.get(index).copied().unwrap_or(0) + } + } + } +} + +impl Coverage { + fn parse(data: &[u8], offset: usize) -> Result { + let r = Reader::new(data); + if offset + 4 > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + + match r.u16(offset)? { + 1 => { + let glyph_count = r.u16(offset + 2)? as usize; + let mut glyphs = Vec::with_capacity(glyph_count); + for i in 0..glyph_count { + let pos = offset + 4 + i * 2; + if pos + 2 > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + glyphs.push(r.u16(pos)?); + } + Ok(Coverage::Format1(glyphs)) + } + 2 => { + let range_count = r.u16(offset + 2)? as usize; + let mut ranges = Vec::with_capacity(range_count); + for i in 0..range_count { + let pos = offset + 4 + i * 6; + if pos + 6 > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + ranges.push(CoverageRange { + start: r.u16(pos)?, + end: r.u16(pos + 2)?, + start_index: r.u16(pos + 4)?, + }); + } + Ok(Coverage::Format2(ranges)) + } + _ => Err(FontError::MalformedTable("GPOS")), + } + } + + fn index(&self, glyph: u16) -> Option { + match self { + Coverage::Format1(glyphs) => glyphs.binary_search(&glyph).ok(), + Coverage::Format2(ranges) => ranges.iter().find_map(|range| { + if glyph >= range.start && glyph <= range.end { + Some((range.start_index + (glyph - range.start)) as usize) + } else { + None + } + }), + } + } +} + +impl ClassDef { + fn parse(data: &[u8], offset: usize) -> Result { + let r = Reader::new(data); + if offset + 4 > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + + match r.u16(offset)? { + 1 => { + if offset + 6 > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + let start = r.u16(offset + 2)?; + let glyph_count = r.u16(offset + 4)? as usize; + let mut classes = Vec::with_capacity(glyph_count); + for i in 0..glyph_count { + let pos = offset + 6 + i * 2; + if pos + 2 > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + classes.push(r.u16(pos)?); + } + Ok(ClassDef::Format1 { start, classes }) + } + 2 => { + let range_count = r.u16(offset + 2)? as usize; + let mut ranges = Vec::with_capacity(range_count); + for i in 0..range_count { + let pos = offset + 4 + i * 6; + if pos + 6 > r.len() { + return Err(FontError::MalformedTable("GPOS")); + } + ranges.push(ClassRange { + start: r.u16(pos)?, + end: r.u16(pos + 2)?, + class: r.u16(pos + 4)?, + }); + } + Ok(ClassDef::Format2(ranges)) + } + _ => Err(FontError::MalformedTable("GPOS")), + } + } + + fn class(&self, glyph: u16) -> u16 { + match self { + ClassDef::Format1 { start, classes } => { + if glyph < *start { + return 0; + } + let index = (glyph - *start) as usize; + classes.get(index).copied().unwrap_or(0) + } + ClassDef::Format2(ranges) => ranges + .iter() + .find_map(|range| { + if glyph >= range.start && glyph <= range.end { + Some(range.class) + } else { + None + } + }) + .unwrap_or(0), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn push_u16(data: &mut Vec, value: u16) { + data.extend_from_slice(&value.to_be_bytes()); + } + + fn push_i16(data: &mut Vec, value: i16) { + data.extend_from_slice(&value.to_be_bytes()); + } + + fn push_u32(data: &mut Vec, value: u32) { + data.extend_from_slice(&value.to_be_bytes()); + } + + fn gpos_with_single_lookup(pair_pos: Vec) -> Vec { + let feature_list_offset = 10usize; + let feature_list_len = 14usize; + let lookup_list_offset = feature_list_offset + feature_list_len; + let lookup_offset = lookup_list_offset + 4; + let pair_pos_offset = lookup_offset + 8; + + let mut data = Vec::new(); + push_u16(&mut data, 1); // major + push_u16(&mut data, 0); // minor + push_u16(&mut data, 0); // scriptListOffset (unused) + push_u16(&mut data, feature_list_offset as u16); + push_u16(&mut data, lookup_list_offset as u16); + + push_u16(&mut data, 1); // featureCount + data.extend_from_slice(b"kern"); + push_u16(&mut data, 8); // feature table offset + push_u16(&mut data, 0); // featureParams + push_u16(&mut data, 1); // lookupIndexCount + push_u16(&mut data, 0); // lookupListIndex + + push_u16(&mut data, 1); // lookupCount + push_u16(&mut data, (lookup_offset - lookup_list_offset) as u16); + + push_u16(&mut data, 2); // lookupType PairPos + push_u16(&mut data, 0); // lookupFlag + push_u16(&mut data, 1); // subTableCount + push_u16(&mut data, (pair_pos_offset - lookup_offset) as u16); + data.extend_from_slice(&pair_pos); + data + } + + fn pair_pos_format1(value: i16) -> Vec { + let mut data = Vec::new(); + push_u16(&mut data, 1); // posFormat + push_u16(&mut data, 12); // coverageOffset + push_u16(&mut data, 0x0004); // valueFormat1: xAdvance + push_u16(&mut data, 0); // valueFormat2 + push_u16(&mut data, 1); // pairSetCount + push_u16(&mut data, 18); // pairSetOffset[0] + + push_u16(&mut data, 1); // coverage format 1 + push_u16(&mut data, 1); // glyphCount + push_u16(&mut data, 1); // left glyph + + push_u16(&mut data, 1); // pairValueCount + push_u16(&mut data, 2); // second glyph + push_i16(&mut data, value); + data + } + + fn pair_pos_format2(value: i16) -> Vec { + let mut data = Vec::new(); + push_u16(&mut data, 2); // posFormat + push_u16(&mut data, 24); // coverageOffset + push_u16(&mut data, 0x0004); // valueFormat1: xAdvance + push_u16(&mut data, 0); // valueFormat2 + push_u16(&mut data, 30); // classDef1Offset + push_u16(&mut data, 38); // classDef2Offset + push_u16(&mut data, 2); // class1Count + push_u16(&mut data, 2); // class2Count + + // Class1Record[0], Class2Record[0..2] + push_i16(&mut data, 0); + push_i16(&mut data, 0); + // Class1Record[1], Class2Record[0..2] + push_i16(&mut data, 0); + push_i16(&mut data, value); + + push_u16(&mut data, 1); // coverage format 1 + push_u16(&mut data, 1); // glyphCount + push_u16(&mut data, 1); // covered left glyph + + push_u16(&mut data, 1); // class def 1 format + push_u16(&mut data, 1); // startGlyph + push_u16(&mut data, 1); // glyphCount + push_u16(&mut data, 1); // class value for glyph 1 + + push_u16(&mut data, 1); // class def 2 format + push_u16(&mut data, 2); // startGlyph + push_u16(&mut data, 1); // glyphCount + push_u16(&mut data, 1); // class value for glyph 2 + data + } + + #[test] + fn parses_pair_pos_format1_x_advance() { + let gpos = GposTable::parse(&gpos_with_single_lookup(pair_pos_format1(-100))).unwrap(); + + assert_eq!(gpos.num_subtables(), 1); + assert_eq!(gpos.adjustment(1, 2), -100); + assert_eq!(gpos.adjustment(2, 1), 0); + } + + #[test] + fn parses_pair_pos_format2_class_x_advance() { + let gpos = GposTable::parse(&gpos_with_single_lookup(pair_pos_format2(-80))).unwrap(); + + assert_eq!(gpos.num_subtables(), 1); + assert_eq!(gpos.adjustment(1, 2), -80); + assert_eq!(gpos.adjustment(1, 1), 0); + assert_eq!(gpos.adjustment(3, 2), 0); + } + + #[test] + fn value_record_size_counts_supported_fields() { + assert_eq!(value_record_size(0), 0); + assert_eq!(value_record_size(0x0004), 2); + assert_eq!(value_record_size(0x00ff), 16); + } + + #[test] + fn empty_when_no_lookup_list() { + let mut data = Vec::new(); + push_u16(&mut data, 1); + push_u16(&mut data, 0); + push_u16(&mut data, 0); + push_u16(&mut data, 0); + push_u16(&mut data, 0); + + let gpos = GposTable::parse(&data).unwrap(); + assert_eq!(gpos.num_subtables(), 0); + assert_eq!(gpos.adjustment(1, 2), 0); + } + + #[test] + fn parses_extension_lookup_wrapping_pair_pos() { + let pair = pair_pos_format1(-60); + let extension_lookup_offset = 10usize + 14usize + 4usize; + let extension_subtable_offset = extension_lookup_offset + 8usize; + let pair_pos_offset = extension_subtable_offset + 8usize; + + let mut data = Vec::new(); + push_u16(&mut data, 1); + push_u16(&mut data, 0); + push_u16(&mut data, 0); + push_u16(&mut data, 10); + push_u16(&mut data, 24); + push_u16(&mut data, 1); + data.extend_from_slice(b"kern"); + push_u16(&mut data, 8); + push_u16(&mut data, 0); + push_u16(&mut data, 1); + push_u16(&mut data, 0); + push_u16(&mut data, 1); + push_u16(&mut data, 4); + push_u16(&mut data, 9); // extension lookup + push_u16(&mut data, 0); + push_u16(&mut data, 1); + push_u16( + &mut data, + (extension_subtable_offset - extension_lookup_offset) as u16, + ); + push_u16(&mut data, 1); // extension format + push_u16(&mut data, 2); // wrapped PairPos lookup + push_u32( + &mut data, + (pair_pos_offset - extension_subtable_offset) as u32, + ); + data.extend_from_slice(&pair); + + let gpos = GposTable::parse(&data).unwrap(); + assert_eq!(gpos.adjustment(1, 2), -60); + } +} diff --git a/crates/text/src/font/tables/mod.rs b/crates/text/src/font/tables/mod.rs index 106be57..0597e41 100644 --- a/crates/text/src/font/tables/mod.rs +++ b/crates/text/src/font/tables/mod.rs @@ -2,6 +2,7 @@ pub mod cmap; pub mod glyf; +pub mod gpos; pub mod head; pub mod hhea; pub mod hmtx; -- 2.51.2