Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions apps/benchmarks/src/generated/package-sizes.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions docs/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/packages/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/packages/glyph.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 18 additions & 15 deletions packages/glyph/rust/shaper/src/engine/cluster_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -101,20 +103,20 @@ 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 {
0.0
} 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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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);
}

Expand Down
29 changes: 28 additions & 1 deletion packages/glyph/rust/shaper/src/engine/flow_composition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<FontMetrics> + Copy,
first_font_for_stack: impl Fn(u32) -> Option<u32> + Copy,
Expand Down Expand Up @@ -415,6 +419,7 @@ impl FlowLayoutArena {
cursor,
(slot.end - slot.start - indent).max(0.0),
wrap,
word_space_shrink,
)?
else {
break;
Expand Down Expand Up @@ -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<f64, EngineError> {
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
Expand Down
29 changes: 17 additions & 12 deletions packages/glyph/rust/shaper/src/engine/layout_query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -218,7 +220,7 @@ pub(crate) fn flow_extents(
flow: &FlowLayoutArena,
text: &[u16],
clusters: &ClusterArena,
first_line_indent: f64,
typography: ThreadTypography,
) -> Result<LayoutExtents, EngineError> {
let mut extents = LayoutExtents::default();
for (index, line) in flow.lines.iter().copied().enumerate() {
Expand All @@ -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
Expand All @@ -254,7 +251,7 @@ fn line_inline_extent(
index: usize,
text: &[u16],
clusters: &ClusterArena,
first_line_indent: f64,
typography: ThreadTypography,
) -> Result<f64, EngineError> {
let fragments = line_fragments(flow, line)?;
if fragments.is_empty() {
Expand All @@ -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))
Expand Down
Loading
Loading