diff --git a/apps/benchmarks/src/generated/package-sizes.json b/apps/benchmarks/src/generated/package-sizes.json index 8187195e..c2363cdb 100644 --- a/apps/benchmarks/src/generated/package-sizes.json +++ b/apps/benchmarks/src/generated/package-sizes.json @@ -43,11 +43,11 @@ "label": "Shaper Wasm", "status": "measured", "format": "wasm", - "sha256": "ba624a8b291820cab752ed74e9e28db7a7b81624322131edec905ffc706b82c0", - "rawBytes": 1108292, - "minifiedBytes": 1108292, - "gzipBytes": 428817, - "brotliBytes": 338777 + "sha256": "f798cae5df8aaf379b59b1d81cdd19068143611532dd1c37f4b8ec19c9fc7d11", + "rawBytes": 1106070, + "minifiedBytes": 1106070, + "gzipBytes": 428166, + "brotliBytes": 338258 }, { "id": "three-runtime-js", diff --git a/docs/log.md b/docs/log.md index 2c0d8260..f1540744 100644 --- a/docs/log.md +++ b/docs/log.md @@ -13,6 +13,17 @@ ## 2026-08-12 +- **Justification controls (11.14, layer 3)** — Justify grows professional bounds. Word spaces expand uniformly up + to the declared maximum ratio of their natural advance sum; the remaining deficit spills into inter-cluster + letter gaps bounded per gap, and any residue reads as an under-full line. A declared minimum ratio makes spaces + elastic in the other direction twice over: the line breaker lends the shrinkable fraction back while scanning — + admitting the word that would otherwise just overflow, via a new `CLUSTER_SPACE` flag stamped at cluster build — + and the positioning pass compresses those spaces to exactly the same bound. The last-line policy (`auto` | + `justify`) now also covers hard-broken lines. Measurement mirrors every branch through the shared + `positioned_fragment_advance`. Proven red-green with distribution unit tests (cap spill, shrink clamp, last-line + gates), a breaker admission test, and a Three integration segment: an unbounded justified last line fills its + exact box, while capped word growth plus a 0.5 letter-gap bound lands at natural-plus-gaps exactly. + - **Paragraph spacing and first-line indent (11.14, layer 2)** — The typography controls begin steering layout: `spaceBefore` shifts a thread's first band exactly once where the paragraph truly starts (resumed threads and region breaks swallow it, matching fragmentation convention), `spaceAfter` rides every block measurement so diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 869adad3..e1013155 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/glyph-benchmarks' documentation_type: reference -source_digest: 'sha256:0105ef0ebb11bc71457b4645ea959b7081edc9c710c216a296571adf860ed8da' +source_digest: 'sha256:b771d35e801acf08e10cd2b7e7da0452c9ec1a1845bfa90e65b1aa295ff4675b' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest diff --git a/docs/packages/glyph.md b/docs/packages/glyph.md index 4c0ac006..850bc03e 100644 --- a/docs/packages/glyph.md +++ b/docs/packages/glyph.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/glyph workspace_package: '@pmndrs/glyph' documentation_type: reference -source_digest: 'sha256:8a892cae9005e4159cd056228dfc47e3f144794cb950fbd96725309608383432' +source_digest: 'sha256:af3db3822c6edecccdb336677a1067b1435035549b45712e6be4b0caabb95b1f' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest diff --git a/packages/glyph/rust/shaper/src/engine/cluster_state.rs b/packages/glyph/rust/shaper/src/engine/cluster_state.rs index b649a454..95956404 100644 --- a/packages/glyph/rust/shaper/src/engine/cluster_state.rs +++ b/packages/glyph/rust/shaper/src/engine/cluster_state.rs @@ -13,6 +13,8 @@ pub(crate) const CLUSTER_SAFE_BEFORE: u8 = 1 << 0; pub(crate) const CLUSTER_REQUIRED_BREAK: u8 = 1 << 1; pub(crate) const CLUSTER_HARD_BREAK: u8 = 1 << 2; pub(crate) const CLUSTER_ALLOWED_BREAK: u8 = 1 << 3; +/// The cluster starts with U+0020 — a justifiable, shrinkable word space. +pub(crate) const CLUSTER_SPACE: u8 = 1 << 4; const GLYPH_UNSAFE_TO_BREAK: u16 = 1; const NO_SOURCE_RUN: u32 = u32::MAX; @@ -101,11 +103,8 @@ impl ClusterArena { return Err(EngineError::InvalidRequest); } let hard_break = is_hard_break(text, start)?; - let word_spacing = if text.get(start as usize) == Some(&0x20) { - style.style.word_spacing - } else { - 0.0 - }; + let space = text.get(start as usize) == Some(&0x20); + let word_spacing = if space { style.style.word_spacing } else { 0.0 }; self.starts.push(start); self.ends.push(end); self.advances.push(if hard_break { @@ -113,8 +112,11 @@ impl ClusterArena { } else { f64::from(style.style.letter_spacing + word_spacing) }); - self.flags - .push(if hard_break { CLUSTER_HARD_BREAK } else { 0 }); + self.flags.push(match (hard_break, space) { + (true, _) => CLUSTER_HARD_BREAK, + (false, true) => CLUSTER_SPACE, + (false, false) => 0, + }); self.style_indexes .push(u32::try_from(style_index).map_err(|_| EngineError::ResultTooLarge)?); self.source_runs.push(NO_SOURCE_RUN); @@ -216,17 +218,18 @@ impl ClusterArena { return Ok(None); } let hard_break = is_hard_break(text, start)?; - let word_spacing = if text.get(start as usize) == Some(&0x20) { - style.style.word_spacing - } else { - 0.0 - }; + let space = text.get(start as usize) == Some(&0x20); + let word_spacing = if space { style.style.word_spacing } else { 0.0 }; self.advances[cluster] = if hard_break { 0.0 } else { f64::from(style.style.letter_spacing + word_spacing) }; - self.flags[cluster] = if hard_break { CLUSTER_HARD_BREAK } else { 0 }; + self.flags[cluster] = match (hard_break, space) { + (true, _) => CLUSTER_HARD_BREAK, + (false, true) => CLUSTER_SPACE, + (false, false) => 0, + }; self.source_runs[cluster] = NO_SOURCE_RUN; self.binding_handles[cluster] = 0; self.font_handles[cluster] = 0; @@ -853,7 +856,7 @@ mod tests { assert_eq!(clusters.flags[0], CLUSTER_SAFE_BEFORE); assert_eq!( clusters.flags[1], - CLUSTER_SAFE_BEFORE | CLUSTER_ALLOWED_BREAK + CLUSTER_SAFE_BEFORE | CLUSTER_ALLOWED_BREAK | CLUSTER_SPACE ); assert_eq!(clusters.flags[2], CLUSTER_SAFE_BEFORE); assert_eq!( @@ -896,7 +899,7 @@ mod tests { clusters.index_at.capacity(), ) ); - assert_eq!(clusters.flags[1], CLUSTER_SAFE_BEFORE); + assert_eq!(clusters.flags[1], CLUSTER_SAFE_BEFORE | CLUSTER_SPACE); assert_eq!(clusters.flags[2], 0); } diff --git a/packages/glyph/rust/shaper/src/engine/flow_composition.rs b/packages/glyph/rust/shaper/src/engine/flow_composition.rs index c899d958..8f7059da 100644 --- a/packages/glyph/rust/shaper/src/engine/flow_composition.rs +++ b/packages/glyph/rust/shaper/src/engine/flow_composition.rs @@ -6,8 +6,9 @@ use super::{ EngineError, cluster_state::{CLUSTER_HARD_BREAK, ClusterArena}, flow_geometry::{FlowGeometryArena, InlineSlotArena}, - frame::{OVERFLOW_CLIP, OVERFLOW_ELLIPSIS, WRITING_HORIZONTAL_TB}, + frame::{ALIGN_JUSTIFY, OVERFLOW_CLIP, OVERFLOW_ELLIPSIS, WRITING_HORIZONTAL_TB}, line_composition::{ComposedLine, LineCursor, layout_next_line}, + semantic_wire::FlowConstraint, style_state::StyleSegment, }; @@ -184,6 +185,7 @@ impl FlowLayoutArena { constraint.wrap, constraint.align, f64::from(constraint.first_line_indent), + constraint_word_space_shrink(&constraint), max_slots_per_band, metrics_for, first_font_for_stack, @@ -310,6 +312,7 @@ impl FlowLayoutArena { wrapping_for_flow_thread(geometry, old_line.flow_thread_id)?, old_line.align, indent_for_flow_thread(geometry, old_line.flow_thread_id)?, + shrink_for_flow_thread(geometry, old_line.flow_thread_id)?, max_slots_per_band, metrics_for, first_font_for_stack, @@ -373,6 +376,7 @@ impl FlowLayoutArena { wrap: u8, align: u8, first_line_indent: f64, + word_space_shrink: f64, max_slots: usize, metrics_for: impl Fn(u32) -> Option + Copy, first_font_for_stack: impl Fn(u32) -> Option + Copy, @@ -415,6 +419,7 @@ impl FlowLayoutArena { cursor, (slot.end - slot.start - indent).max(0.0), wrap, + word_space_shrink, )? else { break; @@ -593,6 +598,28 @@ fn indent_for_flow_thread( .ok_or(EngineError::InvalidRequest) } +/// The breaker's shrink fraction: only a justified thread with a declared +/// minimum word-space ratio may compress spaces to admit one more word. +fn constraint_word_space_shrink(constraint: &FlowConstraint) -> f64 { + if constraint.align == ALIGN_JUSTIFY && constraint.justify_min_word_space_ratio > 0.0 { + 1.0 - f64::from(constraint.justify_min_word_space_ratio) + } else { + 0.0 + } +} + +fn shrink_for_flow_thread( + geometry: &FlowGeometryArena, + flow_thread_id: u32, +) -> Result { + geometry + .constraints + .iter() + .find(|constraint| constraint.flow_thread_id == flow_thread_id) + .map(constraint_word_space_shrink) + .ok_or(EngineError::InvalidRequest) +} + fn line_fragments(flow: &FlowLayoutArena, line: FlowLine) -> Result<&[FlowFragment], EngineError> { let start = usize::try_from(line.fragment_start).map_err(|_| EngineError::InvalidRequest)?; let end = start diff --git a/packages/glyph/rust/shaper/src/engine/layout_query.rs b/packages/glyph/rust/shaper/src/engine/layout_query.rs index a81b0cab..30a40fae 100644 --- a/packages/glyph/rust/shaper/src/engine/layout_query.rs +++ b/packages/glyph/rust/shaper/src/engine/layout_query.rs @@ -11,7 +11,9 @@ use super::{ flow_composition::{FlowFragment, FlowLayoutArena, FlowLine}, flow_geometry::FlowGeometryArena, frame::{AXIS_AT_MOST, AXIS_EXACT, AXIS_UNCONSTRAINED}, - positioning::{SemanticGlyph, positioned_fragment_advance}, + positioning::{ + SemanticGlyph, ThreadTypography, constraint_typography, positioned_fragment_advance, + }, semantic_view::{ SEMANTIC_GLYPH, SEMANTIC_LINE, SEMANTIC_PARAGRAPH_MEASUREMENT, SemanticRecord, }, @@ -110,7 +112,7 @@ pub(crate) fn append_measurement( index, text, clusters, - f64::from(constraint.first_line_indent), + constraint_typography(constraint), )? }; content_width = content_width.max(advance); @@ -218,7 +220,7 @@ pub(crate) fn flow_extents( flow: &FlowLayoutArena, text: &[u16], clusters: &ClusterArena, - first_line_indent: f64, + typography: ThreadTypography, ) -> Result { let mut extents = LayoutExtents::default(); for (index, line) in flow.lines.iter().copied().enumerate() { @@ -233,12 +235,7 @@ pub(crate) fn flow_extents( continue; }; extents.width = extents.width.max(line_inline_extent( - flow, - line, - index, - text, - clusters, - first_line_indent, + flow, line, index, text, clusters, typography, )?); extents.height = extents.height.max(line.block_start + line.height); extents.consumed_clusters = extents @@ -254,7 +251,7 @@ fn line_inline_extent( index: usize, text: &[u16], clusters: &ClusterArena, - first_line_indent: f64, + typography: ThreadTypography, ) -> Result { let fragments = line_fragments(flow, line)?; if fragments.is_empty() { @@ -271,13 +268,21 @@ fn line_inline_extent( let mut inline_end = f64::NEG_INFINITY; for fragment in fragments.iter().copied() { let indent = if fragment.line.cluster_start == 0 { - first_line_indent + typography.first_line_indent } else { 0.0 }; inline_end = inline_end.max( fragment.slot_start - + positioned_fragment_advance(line, fragment, final_line, text, clusters, indent)?, + + positioned_fragment_advance( + line, + fragment, + final_line, + text, + clusters, + indent, + typography.justify, + )?, ); } Ok((inline_end - inline_start).max(0.0)) diff --git a/packages/glyph/rust/shaper/src/engine/line_composition.rs b/packages/glyph/rust/shaper/src/engine/line_composition.rs index a841d2cd..1e0479db 100644 --- a/packages/glyph/rust/shaper/src/engine/line_composition.rs +++ b/packages/glyph/rust/shaper/src/engine/line_composition.rs @@ -2,7 +2,7 @@ use super::{ EngineError, cluster_state::{ CLUSTER_ALLOWED_BREAK, CLUSTER_HARD_BREAK, CLUSTER_REQUIRED_BREAK, CLUSTER_SAFE_BEFORE, - ClusterArena, + CLUSTER_SPACE, ClusterArena, }, frame::{WRAP_CHARACTER, WRAP_NONE, WRAP_WORD}, }; @@ -45,9 +45,11 @@ pub(crate) fn layout_next_line( cursor: &mut LineCursor, max_width: f64, wrap: u8, + word_space_shrink: f64, ) -> Result, EngineError> { if max_width.is_nan() || max_width < 0.0 + || !(0.0..1.0).contains(&word_space_shrink) || !matches!(wrap, WRAP_NONE | WRAP_WORD | WRAP_CHARACTER) { return Err(EngineError::InvalidRequest); @@ -76,6 +78,7 @@ pub(crate) fn layout_next_line( let line_start = cursor.cluster; let mut advance = 0.0; + let mut shrinkable = 0.0; let mut last_allowed = None; let mut last_allowed_advance = 0.0; let mut last_safe = None; @@ -91,9 +94,17 @@ pub(crate) fn layout_next_line( } let required_break = flags & CLUSTER_REQUIRED_BREAK != 0; let next_advance = advance + clusters.advances[index]; + // Declared word-space shrink lends back a fraction of every consumed + // space, admitting the word that would otherwise just overflow; the + // justification pass compresses those spaces to the same bound. + let next_shrinkable = if flags & CLUSTER_SPACE != 0 { + shrinkable + clusters.advances[index] * word_space_shrink + } else { + shrinkable + }; if wrap != WRAP_NONE && max_width.is_finite() - && next_advance > max_width + && next_advance - next_shrinkable > max_width && index > line_start { if let Some(end) = last_allowed.filter(|end| *end > line_start) { @@ -114,6 +125,7 @@ pub(crate) fn layout_next_line( break; } advance = next_advance; + shrinkable = next_shrinkable; if required_break { selected_end = index + 1; selected_advance = advance; @@ -180,6 +192,30 @@ mod tests { } } + #[test] + fn declared_word_space_shrink_admits_the_word_that_would_just_overflow() { + // Ten 1.0-advance clusters with one shrinkable space after "aaaa": at + // width 9.5 the rigid line breaks after the space, while a 0.5 shrink + // fraction lends 0.5 back and the whole run fits on one line. + let mut flags = [0_u8; 10]; + flags[4] = CLUSTER_ALLOWED_BREAK | CLUSTER_SPACE; + let clusters = make_clusters(&[1.0; 10], &flags); + let mut rigid = LineCursor::default(); + assert_eq!( + layout_next_line(&clusters, &mut rigid, 9.5, WRAP_WORD, 0.0) + .unwrap() + .unwrap() + .cluster_end, + 5 + ); + let mut elastic = LineCursor::default(); + let line = layout_next_line(&clusters, &mut elastic, 9.5, WRAP_WORD, 0.5) + .unwrap() + .unwrap(); + assert_eq!(line.cluster_end, 10); + assert_eq!(line.advance, 10.0); + } + #[test] fn composes_word_character_and_unwrapped_lines_without_allocating() { let clusters = make_clusters( @@ -193,7 +229,7 @@ mod tests { ); let mut cursor = LineCursor::default(); assert_eq!( - layout_next_line(&clusters, &mut cursor, 10.0, WRAP_WORD).unwrap(), + layout_next_line(&clusters, &mut cursor, 10.0, WRAP_WORD, 0.0).unwrap(), Some(ComposedLine { cluster_start: 0, cluster_end: 2, @@ -204,20 +240,20 @@ mod tests { }) ); assert_eq!( - layout_next_line(&clusters, &mut cursor, 10.0, WRAP_WORD) + layout_next_line(&clusters, &mut cursor, 10.0, WRAP_WORD, 0.0) .unwrap() .unwrap() .cluster_end, 4 ); assert_eq!( - layout_next_line(&clusters, &mut cursor, 10.0, WRAP_WORD).unwrap(), + layout_next_line(&clusters, &mut cursor, 10.0, WRAP_WORD, 0.0).unwrap(), None ); let mut character = LineCursor::default(); assert_eq!( - layout_next_line(&clusters, &mut character, 5.0, WRAP_CHARACTER) + layout_next_line(&clusters, &mut character, 5.0, WRAP_CHARACTER, 0.0) .unwrap() .unwrap() .cluster_end, @@ -225,7 +261,7 @@ mod tests { ); let mut unwrapped = LineCursor::default(); assert_eq!( - layout_next_line(&clusters, &mut unwrapped, 1.0, WRAP_NONE) + layout_next_line(&clusters, &mut unwrapped, 1.0, WRAP_NONE, 0.0) .unwrap() .unwrap() .advance, @@ -237,14 +273,14 @@ mod tests { &[CLUSTER_SAFE_BEFORE, 0, CLUSTER_SAFE_BEFORE], ); let mut unsafe_cursor = LineCursor::default(); - let line = layout_next_line(&unsafe_boundary, &mut unsafe_cursor, 5.0, WRAP_WORD) + let line = layout_next_line(&unsafe_boundary, &mut unsafe_cursor, 5.0, WRAP_WORD, 0.0) .unwrap() .unwrap(); assert_eq!((line.cluster_end, line.advance), (2, 8.0)); let oversized = make_clusters(&[7.0, 3.0], &[CLUSTER_SAFE_BEFORE, CLUSTER_SAFE_BEFORE]); let mut oversized_cursor = LineCursor::default(); - let line = layout_next_line(&oversized, &mut oversized_cursor, 5.0, WRAP_WORD) + let line = layout_next_line(&oversized, &mut oversized_cursor, 5.0, WRAP_WORD, 0.0) .unwrap() .unwrap(); assert_eq!((line.cluster_end, line.advance), (1, 7.0)); @@ -260,7 +296,7 @@ mod tests { ], ); let mut cursor = LineCursor::default(); - let first = layout_next_line(&clusters, &mut cursor, f64::INFINITY, WRAP_WORD) + let first = layout_next_line(&clusters, &mut cursor, f64::INFINITY, WRAP_WORD, 0.0) .unwrap() .unwrap(); assert_eq!( @@ -268,13 +304,13 @@ mod tests { (0, 1, 3.0) ); assert!(first.hard_break); - let trailing = layout_next_line(&clusters, &mut cursor, 0.0, WRAP_WORD) + let trailing = layout_next_line(&clusters, &mut cursor, 0.0, WRAP_WORD, 0.0) .unwrap() .unwrap(); assert_eq!((trailing.cluster_start, trailing.cluster_end), (2, 2)); assert_eq!((trailing.text_start, trailing.text_end), (2, 2)); assert_eq!( - layout_next_line(&clusters, &mut cursor, 0.0, WRAP_WORD).unwrap(), + layout_next_line(&clusters, &mut cursor, 0.0, WRAP_WORD, 0.0).unwrap(), None ); } diff --git a/packages/glyph/rust/shaper/src/engine/positioning.rs b/packages/glyph/rust/shaper/src/engine/positioning.rs index b9f933bb..bef24d4c 100644 --- a/packages/glyph/rust/shaper/src/engine/positioning.rs +++ b/packages/glyph/rust/shaper/src/engine/positioning.rs @@ -142,7 +142,7 @@ impl PositionedGlyphArena { bidi: &BidiAnalysis, identity_index: &mut IdentityIndex, next_content_revision: &mut u32, - indent_for: impl Fn(u32) -> f64 + Copy, + typography_for: impl Fn(u32) -> ThreadTypography + Copy, metrics_for: impl Fn(u32) -> Option + Copy, extents_for: impl Fn(u32, u32) -> Option + Copy, ) -> Result<(), EngineError> { @@ -195,7 +195,7 @@ impl PositionedGlyphArena { .iter() .map(|fragment| fragment.slot_start) .fold(f64::INFINITY, f64::min); - let thread_indent = indent_for(line.flow_thread_id); + let typography = typography_for(line.flow_thread_id); let mut inline_end = f64::NEG_INFINITY; for fragment in fragments.iter().copied() { let fragment_advance = self.position_fragment( @@ -211,10 +211,11 @@ impl PositionedGlyphArena { bidi, visually_ltr, if fragment.line.cluster_start == 0 { - thread_indent + typography.first_line_indent } else { 0.0 }, + typography.justify, metrics_for, extents_for, )?; @@ -423,6 +424,7 @@ impl PositionedGlyphArena { bidi: &BidiAnalysis, visually_ltr: bool, indent: f64, + controls: JustifyControls, metrics_for: impl Fn(u32) -> Option + Copy, extents_for: impl Fn(u32, u32) -> Option + Copy, ) -> Result { @@ -471,7 +473,7 @@ impl PositionedGlyphArena { let available = (fragment.slot_end - fragment.slot_start - indent - fragment.line.advance).max(0.0); let paragraph_level = paragraph_level_at(bidi, fragment.line.text_start); - let (justify_spaces, per_space) = justification_adjustment( + let justify = justification_adjustment( line, fragment, final_line, @@ -480,8 +482,9 @@ impl PositionedGlyphArena { cluster_start, cluster_end, indent, + controls, ); - let offset = if per_space == 0.0 { + let offset = if justify.per_space == 0.0 && justify.per_gap == 0.0 { alignment_offset(line.align, paragraph_level, available) } else { 0.0 @@ -626,8 +629,11 @@ impl PositionedGlyphArena { cursor += x_advance; } cursor = cluster_origin + clusters.advances[cluster]; - if per_space != 0.0 && cluster_is_space(text, clusters, cluster) { - cursor += per_space; + if justify.per_space != 0.0 && cluster_is_space(text, clusters, cluster) { + cursor += justify.per_space; + } + if justify.per_gap != 0.0 && cluster + 1 < justify.gap_end { + cursor += justify.per_gap; } if style.decoration_flags == 0 { if decorated_run.is_some() { @@ -673,7 +679,10 @@ impl PositionedGlyphArena { extents_for, )?; } - Ok(indent + fragment.line.advance + per_space * f64::from(justify_spaces)) + Ok(indent + + fragment.line.advance + + justify.per_space * f64::from(justify.spaces) + + justify.per_gap * f64::from(justify.gaps)) } fn flush_decorated_run( @@ -1322,21 +1331,78 @@ fn alignment_offset(align: u8, paragraph_level: u8, available: f64) -> f64 { } } -fn count_justification_spaces( +/// One flow thread's typography, resolved from its constraint record. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct ThreadTypography { + pub first_line_indent: f64, + pub justify: JustifyControls, +} + +/// Per-thread justification controls carried by the constraint record. Zero +/// ratio fields mean unbounded on that side; the default reproduces the +/// pre-tier equal-space distribution exactly. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct JustifyControls { + pub minimum_word_space_ratio: f32, + pub maximum_word_space_ratio: f32, + pub letter_space_expansion: f32, + pub last_line_justify: bool, +} + +/// Resolve one constraint's typography for positioning and measurement. +pub(crate) fn constraint_typography( + constraint: &super::semantic_wire::FlowConstraint, +) -> ThreadTypography { + ThreadTypography { + first_line_indent: f64::from(constraint.first_line_indent), + justify: JustifyControls { + minimum_word_space_ratio: constraint.justify_min_word_space_ratio, + maximum_word_space_ratio: constraint.justify_max_word_space_ratio, + letter_space_expansion: constraint.justify_letter_space_expansion, + last_line_justify: constraint.last_line == super::frame::LAST_LINE_JUSTIFY, + }, + } +} + +/// One line's resolved justification: uniform word-space delta, bounded +/// letter-gap delta, and the trimmed cluster bound the gaps apply within. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct JustifyDistribution { + pub spaces: u32, + pub per_space: f64, + pub gaps: u32, + pub per_gap: f64, + pub gap_end: usize, +} + +struct JustifiableSpan { + spaces: u32, + space_advance_sum: f64, + trimmed_end: usize, +} + +fn justifiable_span( text: &[u16], clusters: &ClusterArena, start: usize, mut end: usize, -) -> u32 { +) -> JustifiableSpan { while end > start && cluster_is_space(text, clusters, end - 1) { end -= 1; } - clusters.starts[start..end] - .iter() - .filter(|&&offset| text.get(offset as usize) == Some(&0x20)) - .count() - .try_into() - .unwrap_or(u32::MAX) + let mut spaces = 0_u32; + let mut space_advance_sum = 0.0_f64; + for cluster in start..end { + if cluster_is_space(text, clusters, cluster) { + spaces = spaces.saturating_add(1); + space_advance_sum += clusters.advances[cluster]; + } + } + JustifiableSpan { + spaces, + space_advance_sum, + trimmed_end: end, + } } #[allow(clippy::too_many_arguments)] @@ -1349,15 +1415,61 @@ fn justification_adjustment( cluster_start: usize, cluster_end: usize, indent: f64, -) -> (u32, f64) { - let spaces = if line.align == ALIGN_JUSTIFY && !fragment.line.hard_break && !final_line { - count_justification_spaces(text, clusters, cluster_start, cluster_end) + controls: JustifyControls, +) -> JustifyDistribution { + let justified = line.align == ALIGN_JUSTIFY + && (controls.last_line_justify || (!fragment.line.hard_break && !final_line)); + if !justified { + return JustifyDistribution::default(); + } + let span = justifiable_span(text, clusters, cluster_start, cluster_end); + if span.spaces == 0 { + return JustifyDistribution::default(); + } + let deficit = fragment.slot_end - fragment.slot_start - indent - fragment.line.advance; + if deficit >= 0.0 { + // Expansion: word spaces grow uniformly up to the declared cap, then the + // remainder spills into inter-cluster gaps bounded per gap; any residue + // stays unfilled and the line reads as under-full. + let space_growth = if controls.maximum_word_space_ratio > 0.0 { + deficit.min(f64::from(controls.maximum_word_space_ratio - 1.0) * span.space_advance_sum) + } else { + deficit + }; + let gaps = u32::try_from( + span.trimmed_end + .saturating_sub(cluster_start) + .saturating_sub(1), + ) + .unwrap_or(u32::MAX); + let remainder = deficit - space_growth; + let per_gap = if controls.letter_space_expansion > 0.0 && gaps > 0 && remainder > 0.0 { + (remainder / f64::from(gaps)).min(f64::from(controls.letter_space_expansion)) + } else { + 0.0 + }; + JustifyDistribution { + spaces: span.spaces, + per_space: justification_space_advance(space_growth, span.spaces), + gaps, + per_gap, + gap_end: span.trimmed_end, + } + } else if controls.minimum_word_space_ratio > 0.0 { + // Compression: an overfull line shrinks its word spaces uniformly, never + // below the declared minimum of their natural advance sum. + let shrink = deficit + .max(-f64::from(1.0 - controls.minimum_word_space_ratio) * span.space_advance_sum); + JustifyDistribution { + spaces: span.spaces, + per_space: justification_space_advance(shrink, span.spaces), + gaps: 0, + per_gap: 0.0, + gap_end: span.trimmed_end, + } } else { - 0 - }; - let available = - (fragment.slot_end - fragment.slot_start - indent - fragment.line.advance).max(0.0); - (spaces, justification_space_advance(available, spaces)) + JustifyDistribution::default() + } } /// The inline extent one fragment occupies: its (possibly justified) advance @@ -1369,12 +1481,13 @@ pub(crate) fn positioned_fragment_advance( text: &[u16], clusters: &ClusterArena, indent: f64, + controls: JustifyControls, ) -> Result { let cluster_start = usize::try_from(fragment.line.cluster_start).map_err(|_| EngineError::InvalidRequest)?; let cluster_end = usize::try_from(fragment.line.cluster_end).map_err(|_| EngineError::InvalidRequest)?; - let (spaces, per_space) = justification_adjustment( + let distribution = justification_adjustment( line, fragment, final_line, @@ -1383,8 +1496,12 @@ pub(crate) fn positioned_fragment_advance( cluster_start, cluster_end, indent, + controls, ); - Ok(indent + fragment.line.advance + per_space * f64::from(spaces)) + Ok(indent + + fragment.line.advance + + distribution.per_space * f64::from(distribution.spaces) + + distribution.per_gap * f64::from(distribution.gaps)) } fn justification_space_advance(available: f64, space_count: u32) -> f64 { @@ -1487,6 +1604,123 @@ mod tests { assert_eq!(justification_space_advance(22.0, 0), 0.0); } + fn justify_fixture() -> (Vec, ClusterArena, FlowLine, FlowFragment) { + // "ab cd f" — seven 1.0-advance clusters with spaces at 2 and 5. + let text: Vec = "ab cd f".encode_utf16().collect(); + let clusters = ClusterArena { + starts: (0..7).collect(), + ends: (1..=7).collect(), + advances: vec![1.0; 7], + flags: vec![0; 7], + style_indexes: vec![0; 7], + source_runs: vec![0; 7], + font_handles: vec![1; 7], + index_at: (0..=7).collect(), + ..ClusterArena::default() + }; + let line = FlowLine { + flow_thread_id: 1, + region_id: 1, + transform_index: 1, + clip_id: 0, + fragment_start: 0, + fragment_count: 1, + align: ALIGN_JUSTIFY, + block_start: 0.0, + baseline: 4.0, + height: 5.0, + }; + let fragment = FlowFragment { + line: ComposedLine { + cluster_start: 0, + cluster_end: 7, + text_start: 0, + text_end: 7, + advance: 7.0, + hard_break: false, + }, + slot_start: 0.0, + slot_end: 17.0, + boundary_index: NO_BOUNDARY, + }; + (text, clusters, line, fragment) + } + + #[test] + fn word_space_caps_spill_into_bounded_letter_expansion() { + let (text, clusters, line, fragment) = justify_fixture(); + // Deficit 10 over 2 spaces (natural sum 2.0): a 3x cap allows 4.0 of + // word-space growth; 6.0 spills into six inter-cluster gaps bounded to + // 0.75 each; the final 1.5 stays unfilled. + let controls = JustifyControls { + minimum_word_space_ratio: 0.0, + maximum_word_space_ratio: 3.0, + letter_space_expansion: 0.75, + last_line_justify: false, + }; + let distribution = + justification_adjustment(line, fragment, false, &text, &clusters, 0, 7, 0.0, controls); + assert_eq!(distribution.spaces, 2); + assert_eq!(distribution.per_space, 2.0); + assert_eq!(distribution.gaps, 6); + assert_eq!(distribution.per_gap, 0.75); + + // Unbounded controls reproduce the pre-tier distribution exactly. + let unbounded = JustifyControls::default(); + let plain = justification_adjustment( + line, fragment, false, &text, &clusters, 0, 7, 0.0, unbounded, + ); + assert_eq!(plain.per_space, 5.0); + assert_eq!(plain.per_gap, 0.0); + } + + #[test] + fn last_line_policy_justifies_final_and_hard_broken_lines() { + let (text, clusters, line, fragment) = justify_fixture(); + let auto = JustifyControls::default(); + let final_auto = + justification_adjustment(line, fragment, true, &text, &clusters, 0, 7, 0.0, auto); + assert_eq!(final_auto.per_space, 0.0); + let policy = JustifyControls { + last_line_justify: true, + ..JustifyControls::default() + }; + let final_justified = + justification_adjustment(line, fragment, true, &text, &clusters, 0, 7, 0.0, policy); + assert_eq!(final_justified.per_space, 5.0); + } + + #[test] + fn word_spaces_shrink_only_to_the_declared_minimum() { + let (text, clusters, line, mut fragment) = justify_fixture(); + // Overfull by 1.0: a 0.75 minimum permits 0.25 shrink per space (0.5 + // total), so shrink clamps at -0.25 and the line stays 0.5 overfull. + fragment.slot_end = 6.0; + let controls = JustifyControls { + minimum_word_space_ratio: 0.75, + maximum_word_space_ratio: 0.0, + letter_space_expansion: 0.0, + last_line_justify: false, + }; + let shrunk = + justification_adjustment(line, fragment, false, &text, &clusters, 0, 7, 0.0, controls); + assert_eq!(shrunk.per_space, -0.25); + assert_eq!(shrunk.per_gap, 0.0); + // Without a declared minimum an overfull line never shrinks. + let rigid = justification_adjustment( + line, + fragment, + false, + &text, + &clusters, + 0, + 7, + 0.0, + JustifyControls::default(), + ); + assert_eq!(rigid.per_space, 0.0); + } + /// CSS decorating box: a nested font-size change inside one declared decoration /// keeps a single continuous line at the declaring span's geometry. #[test] @@ -1617,7 +1851,7 @@ mod tests { &bidi, &mut index, &mut next_revision, - |_| 0.0, + |_| ThreadTypography::default(), metrics, extents, ) @@ -1755,7 +1989,7 @@ mod tests { &bidi, &mut index, &mut next_revision, - |_| 0.0, + |_| ThreadTypography::default(), metrics, extents, ) @@ -1803,7 +2037,7 @@ mod tests { &bidi, &mut index, &mut next_revision, - |_| 0.0, + |_| ThreadTypography::default(), metrics, extents, ) @@ -1996,7 +2230,7 @@ mod tests { &bidi, &mut index, &mut next_revision, - |_| 0.0, + |_| ThreadTypography::default(), metrics, extents, ) @@ -2138,7 +2372,7 @@ mod tests { &bidi, &mut index, &mut next_revision, - |_| 0.0, + |_| ThreadTypography::default(), metrics, extents, ) @@ -2173,7 +2407,7 @@ mod tests { &bidi, &mut index, &mut next_revision, - |_| 0.0, + |_| ThreadTypography::default(), metrics, extents, ) @@ -2197,7 +2431,7 @@ mod tests { &bidi, &mut index, &mut next_revision, - |_| 0.0, + |_| ThreadTypography::default(), metrics, extents, ) diff --git a/packages/glyph/rust/shaper/src/engine/state.rs b/packages/glyph/rust/shaper/src/engine/state.rs index c9c1ff51..0176d8bb 100644 --- a/packages/glyph/rust/shaper/src/engine/state.rs +++ b/packages/glyph/rust/shaper/src/engine/state.rs @@ -800,7 +800,7 @@ impl TextEngine { flow, text, clusters, - thread_first_line_indent(geometry, flow_thread_id), + thread_typography(geometry, flow_thread_id), )? }; let active_flow = if state.flow_layout_prepared { @@ -891,7 +891,7 @@ impl TextEngine { &state.intrinsic_flow_layout_scratch, text, clusters, - thread_first_line_indent(geometry, flow_thread_id), + thread_typography(geometry, flow_thread_id), )?) } else { None @@ -2752,7 +2752,7 @@ impl ParagraphState { bidi, &mut self.intrinsic_identity_scratch, &mut next_content_revision, - |thread| thread_first_line_indent(geometry, thread), + |thread| thread_typography(geometry, thread), |handle| shaper.font_metrics(handle), |handle, glyph| shaper.font_glyph_extents(handle, glyph), ) @@ -2835,7 +2835,7 @@ impl ParagraphState { bidi, &mut self.glyph_identity_index, next_content_revision, - |thread| thread_first_line_indent(geometry, thread), + |thread| thread_typography(geometry, thread), |handle| shaper.font_metrics(handle), |handle, glyph| shaper.font_glyph_extents(handle, glyph), )?; @@ -3454,14 +3454,17 @@ fn plan_error(error: RenderPlanCompilerError) -> EngineError { } } -/// The paragraph first-line indent for one flow thread; absent threads carry -/// no indent so retained lines from removed constraints position unchanged. -fn thread_first_line_indent(geometry: &FlowGeometryArena, flow_thread_id: u32) -> f64 { +/// The typography for one flow thread; absent threads carry defaults so +/// retained lines from removed constraints position unchanged. +fn thread_typography( + geometry: &FlowGeometryArena, + flow_thread_id: u32, +) -> super::positioning::ThreadTypography { geometry .constraints .iter() .find(|constraint| constraint.flow_thread_id == flow_thread_id) - .map_or(0.0, |constraint| f64::from(constraint.first_line_indent)) + .map_or_else(Default::default, super::positioning::constraint_typography) } fn gather_error(error: GatherError) -> EngineError { diff --git a/packages/glyph/tests/integration/three-v1.test.mjs b/packages/glyph/tests/integration/three-v1.test.mjs index 27d91743..a0cb3780 100644 --- a/packages/glyph/tests/integration/three-v1.test.mjs +++ b/packages/glyph/tests/integration/three-v1.test.mjs @@ -205,6 +205,41 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr paragraph.dispose(); } + // Justification controls: an unbounded justified last line fills the exact + // box; capping word growth at its natural width and bounding letter gaps + // leaves the line short by design. + const justifyBox = (justify, lastLine) => ({ + width: { mode: 'exact', size: 300 }, + align: 'justify', + ...(justify === undefined ? {} : { justify }), + lastLine, + }); + const natural = new Text({ font, text: 'pack my box', contentBox: justifyBox(undefined, 'auto') }); + const filled = new Text({ font, text: 'pack my box', contentBox: justifyBox(undefined, 'justify') }); + const capped = new Text({ + font, + text: 'pack my box', + contentBox: justifyBox({ maxWordSpaceRatio: 1, letterSpaceExpansion: 0.5 }, 'justify'), + }); + for (const paragraph of [natural, filled, capped]) scene.add(paragraph); + scene.updateMatrixWorld(); + const naturalMeasure = natural.measureLayout(); + const filledMeasure = filled.measureLayout(); + const cappedMeasure = capped.measureLayout(); + assert.equal(naturalMeasure.lineCount, 1); + assert.ok(naturalMeasure.contentWidth < 300, 'auto last line keeps its natural advance'); + assert.equal(filledMeasure.contentWidth, 300, 'justified last line fills the exact box'); + const cappedGaps = cappedMeasure.glyphCount - 1; + assert.equal( + cappedMeasure.contentWidth, + naturalMeasure.contentWidth + cappedGaps * 0.5, + 'capped word spaces spill into bounded letter gaps', + ); + for (const paragraph of [natural, filled, capped]) { + paragraph.removeFromParent(); + paragraph.dispose(); + } + label.removeFromParent(); label.dispose(); font.dispose();