Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 a5466741c4 Text: superscript and subscript advance at their drawn size
Superscript and subscript glyphs were drawn at two thirds of the run's size but
advanced by the full size, so the letters spread apart ("s u p e r") and the
line ran wider than in PowerPoint. The cluster advance now uses the same scale
as the glyphs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 13:57:30 -04:00
6 changed files with 51 additions and 186 deletions
-1
View File
@@ -13,7 +13,6 @@ mod morph_path;
mod morph_text;
mod paint;
mod placed;
mod ramp;
mod table;
use std::collections::HashMap;
+3 -4
View File
@@ -35,11 +35,10 @@ pub fn fill_path(
if g.stops.is_empty() {
return;
}
let mut stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0), shade(rctx.color(&s.color, ph), mode))).collect();
let mut stops: Vec<(f32, peniko::Color)> =
g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0) as f32, color(shade(rctx.color(&s.color, ph), mode), alpha))).collect();
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
// PowerPoint's ramp between stops (linear light, S-shaped), see `ramp`.
let stops: Vec<peniko::ColorStop> =
crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect();
let stops: Vec<peniko::ColorStop> = stops.into_iter().map(|(o, c)| peniko::ColorStop { offset: o, color: c.into() }).collect();
let grad = match &g.shape {
GradientShape::Linear { angle, .. } => {
// Angle measured clockwise from left→right; span the bounds along that direction.
-128
View File
@@ -1,128 +0,0 @@
//! Gradient colour ramps as PowerPoint draws them.
//!
//! A gradient of exactly two stops, at 0 % and 100 % (what a two-colour gradient is), is not
//! blended linearly in sRGB by PowerPoint: it mixes the colours in linear light, and the second
//! colour's share follows an S-shaped curve of the position. Black to white is 105 (not 64) a
//! quarter of the way, 186 (not 128) half way and 238 (not 191) three quarters of the way, in any
//! direction, on shapes and on slide backgrounds. Gradients with three or more stops, or with a
//! stop moved off an end, blend linearly in sRGB (measured from PowerPoint's own slide export).
//! The renderer interpolates linearly in sRGB, so a two-colour gradient is expanded into short
//! pieces whose colours follow PowerPoint's ramp.
use deckcraft_color::Rgba;
/// The next stop's share of the linear-light mix at sixteenths of a segment (0 at the first stop,
/// 1 at the second). Measured, then made symmetric: `w(1 - t) = 1 - w(t)`.
const WEIGHT: [f64; 17] =
[0.0, 0.0205, 0.0469, 0.0878, 0.1431, 0.2143, 0.2989, 0.3972, 0.5, 0.6028, 0.7011, 0.7857, 0.8569, 0.9122, 0.9531, 0.9795, 1.0];
/// Pieces each segment between two stops is cut into.
const PIECES: usize = 16;
/// The next stop's share at `t` (0..1) between two stops.
pub fn weight(t: f64) -> f64 {
if !t.is_finite() {
return 0.0;
}
let x = t.clamp(0.0, 1.0) * (WEIGHT.len() - 1) as f64;
let i = (x.floor() as usize).min(WEIGHT.len() - 2);
let f = x - i as f64;
let (a, b) = (WEIGHT.get(i).copied().unwrap_or(0.0), WEIGHT.get(i + 1).copied().unwrap_or(1.0));
a + (b - a) * f
}
fn to_linear(v: u8) -> f64 {
let v = v as f64 / 255.0;
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
}
fn to_srgb(v: f64) -> u8 {
let v = v.clamp(0.0, 1.0);
let s = if v <= 0.0031308 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 };
(s * 255.0).round().clamp(0.0, 255.0) as u8
}
/// The colour at `t` between stops `a` and `b`. Alpha mixes by the same share.
pub fn mix(a: Rgba, b: Rgba, t: f64) -> Rgba {
let w = weight(t);
let ch = |x: u8, y: u8| to_srgb(to_linear(x) + (to_linear(y) - to_linear(x)) * w);
Rgba { r: ch(a.r, b.r), g: ch(a.g, b.g), b: ch(a.b, b.b), a: (a.a as f64 + (b.a as f64 - a.a as f64) * w).round().clamp(0.0, 255.0) as u8 }
}
/// Sorted stops (position 0..1, colour) as a renderer interpolating linearly in sRGB should get
/// them: a two-colour gradient (two stops, at 0 and 1) expanded to follow PowerPoint's ramp,
/// anything else unchanged.
pub fn expand(stops: &[(f64, Rgba)]) -> Vec<(f64, Rgba)> {
let two_colour = matches!(stops, [(p, _), (q, _)] if *p <= 1e-6 && *q >= 1.0 - 1e-6);
if !two_colour {
return stops.to_vec();
}
let mut out = Vec::with_capacity(stops.len() * PIECES + 1);
for (i, &(p, c)) in stops.iter().enumerate() {
out.push((p, c));
let Some(&(q, d)) = stops.get(i + 1) else { continue };
if q - p <= 1e-6 || c == d {
continue;
}
for k in 1..PIECES {
let t = k as f64 / PIECES as f64;
out.push((p + (q - p) * t, mix(c, d, t)));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn black_to_white_follows_powerpoint() {
// PowerPoint's export: 105 at a quarter, 186 half way, 238 at three quarters.
let (k, w) = (Rgba::rgb(0, 0, 0), Rgba::rgb(255, 255, 255));
for (t, want) in [(0.25, 105), (0.5, 186), (0.75, 238)] {
let got = mix(k, w, t).r as i32;
assert!((got - want).abs() <= 3, "{t}: {got} vs {want}");
}
}
#[test]
fn colour_pairs_follow_powerpoint() {
// Blue (4472C4) to orange (ED7D31) half way: PowerPoint draws (178, 120, 146).
let m = mix(Rgba::rgb(68, 114, 196), Rgba::rgb(237, 125, 49), 0.5);
assert!((m.r as i32 - 178).abs() <= 3 && (m.g as i32 - 120).abs() <= 3 && (m.b as i32 - 146).abs() <= 3, "{m:?}");
// Red to blue a quarter of the way: (238, 0, 105).
let m = mix(Rgba::rgb(255, 0, 0), Rgba::rgb(0, 0, 255), 0.25);
assert!((m.r as i32 - 238).abs() <= 3 && (m.b as i32 - 105).abs() <= 3, "{m:?}");
}
#[test]
fn the_ends_are_the_stops_and_the_weight_is_symmetric() {
let (a, b) = (Rgba::rgb(10, 200, 30), Rgba::rgb(240, 20, 90));
assert_eq!(mix(a, b, 0.0), a);
assert_eq!(mix(a, b, 1.0), b);
for k in 0..=32 {
let t = k as f64 / 32.0;
assert!((weight(t) + weight(1.0 - t) - 1.0).abs() < 1e-9);
assert!(weight(t) <= weight((t + 0.01).min(1.0)), "monotonic");
}
assert_eq!(weight(f64::NAN), 0.0);
}
#[test]
fn expand_keeps_stops_and_hard_edges() {
let (r, g) = (Rgba::rgb(255, 0, 0), Rgba::rgb(0, 255, 0));
// Three or more stops, or a stop off an end, blend linearly in sRGB: unchanged.
let e = expand(&[(0.0, r), (0.5, r), (0.5, g), (1.0, g)]);
assert_eq!(e, vec![(0.0, r), (0.5, r), (0.5, g), (1.0, g)]);
assert_eq!(expand(&[(0.2, r), (1.0, g)]), vec![(0.2, r), (1.0, g)]);
assert_eq!(expand(&[(0.0, r), (0.5, g), (1.0, r)]).len(), 3);
// Two equal colours add nothing.
assert_eq!(expand(&[(0.0, r), (1.0, r)]), vec![(0.0, r), (1.0, r)]);
let e = expand(&[(0.0, r), (1.0, g)]);
assert_eq!(e.len(), PIECES + 1);
assert_eq!(e.first(), Some(&(0.0, r)));
assert_eq!(e.last(), Some(&(1.0, g)));
assert!(e.windows(2).all(|w| w[0].0 <= w[1].0));
}
}
-49
View File
@@ -523,52 +523,3 @@ fn hyperlinks_draw_in_the_hyperlink_colour_underlined() {
let own = px(&run_deck(RunProps { link, fill: red.clone(), ..Default::default() }));
assert_eq!(diff(&own, &px(&run_deck(RunProps { fill: red, underline: Some("sng".into()), ..Default::default() }))), 0);
}
#[test]
fn a_linear_gradient_ramps_like_powerpoint() {
// Black to white, left to right: PowerPoint draws 105 a quarter of the way and 186 half way
// (linear-light mix, S-shaped), not 64 and 128.
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
let stop = |pos, c| GradientStop { pos, color: ColorRef::rgb(c) };
let sh = Shape {
id: ShapeId(700),
xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)),
fill: Some(Fill::Gradient(Gradient {
stops: vec![stop(0.0, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))],
shape: GradientShape::Linear { angle: 0.0, scaled: false },
rotate_with_shape: true,
})),
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
..Default::default()
};
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
for (x, want) in [(200, 105), (400, 186), (600, 238)] {
let got = img.pixel(x, 50)[0] as i32;
assert!((got - want).abs() <= 4, "x {x}: {got} vs {want}");
}
}
#[test]
fn a_gradient_with_a_stop_off_the_end_stays_linear() {
// Stops at 20 % and 100 %: PowerPoint blends linearly in sRGB (96 at 50 %, 175 at 75 %).
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
let stop = |pos, c| GradientStop { pos, color: ColorRef::rgb(c) };
let sh = Shape {
id: ShapeId(701),
xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)),
fill: Some(Fill::Gradient(Gradient {
stops: vec![stop(0.2, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))],
shape: GradientShape::Linear { angle: 0.0, scaled: false },
rotate_with_shape: true,
})),
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
..Default::default()
};
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
for (x, want) in [(400, 96), (600, 175)] {
let got = img.pixel(x, 50)[0] as i32;
assert!((got - want).abs() <= 4, "x {x}: {got} vs {want}");
}
}
+10 -4
View File
@@ -566,6 +566,9 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
cells
}
/// Size of superscript and subscript text relative to the run's size.
const SUB_SUPER: f64 = 0.66;
/// Shape cells `range` (one item) and hang each cluster's glyphs on its first cell, sharing the
/// cluster's advance among its graphemes (by the ligature's GDEF carets when it has them).
#[allow(clippy::too_many_arguments)]
@@ -593,7 +596,11 @@ fn shape_item(
let st = styles.get(cell.style);
let size = st.map(|s| s.size).unwrap_or(18.0);
let small = st.is_some_and(|s| s.caps == Caps::Small) && cell.ch.is_lowercase();
(if small { size * 0.8 } else { size }) / upem
let size = if small { size * 0.8 } else { size };
// Superscript and subscript glyphs are drawn at two thirds of the size, so they advance by
// two thirds too.
let size = if st.is_some_and(|s| s.baseline != 0.0) { size * SUB_SUPER } else { size };
size / upem
};
// Cluster heads in this item, ascending; each cluster spans to the next head.
let mut heads: Vec<usize> = glyphs.iter().filter_map(|g| char_at_byte.get(&g.cluster).copied()).filter(|h| range.contains(h)).collect();
@@ -1155,7 +1162,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
let Some(st) = p.styles.get(cell.style) else { continue };
let Some(&(cx, cx1)) = line.edges.get(k) else { continue };
let base_y = line.baseline - st.baseline * st.size;
let size = if st.baseline != 0.0 { st.size * 0.66 } else { st.size };
let size = if st.baseline != 0.0 { st.size * SUB_SUPER } else { st.size };
let size = if st.caps == Caps::Small && cell.ch.is_lowercase() { size * 0.8 } else { size };
let key = (cell.style, cell.face);
if key != cur_key {
@@ -1204,9 +1211,8 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
}
}
}
let k66 = if st.baseline != 0.0 { 0.66 } else { 1.0 };
for (gid, gx, gy) in &cell.glyphs {
r.glyphs.push((*gid, cl + kash + gx * k66, base_y + gy));
r.glyphs.push((*gid, cl + kash + gx, base_y + gy));
r.cells.push((ci, cell.ch));
}
}
+38
View File
@@ -395,3 +395,41 @@ fn hostile_bidi_input_never_panics() {
}
}
}
/// Width of `word` laid out in a run with `baseline` (0 = normal, 0.3 = superscript …).
fn run_width(word: &str, baseline: Option<f64>) -> f64 {
let mut run = Run::new(word);
run.props.size = Some(24.0);
run.props.baseline = baseline;
let (p, sh) = bidi_setup(vec![run], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
let line = &l.lines[0];
line.caret_x.last().unwrap() - line.caret_x.first().unwrap()
}
#[test]
fn superscript_and_subscript_advance_at_their_drawn_size() {
// The glyphs are drawn at two thirds of the size; advancing by the full size spread the
// letters apart ("s u p e r").
let normal = run_width("superscript", None);
for b in [Some(0.3), Some(-0.25)] {
let w = run_width("superscript", b);
assert!((w / normal - SUB_SUPER).abs() < 0.02, "{b:?}: {w} vs {normal}");
}
}
#[test]
fn superscript_glyphs_sit_inside_their_cells() {
let mut run = Run::new("super");
run.props.size = Some(24.0);
run.props.baseline = Some(0.3);
let (p, sh) = bidi_setup(vec![run], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
let line = &l.lines[0];
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
assert!((r.size - 24.0 * SUB_SUPER).abs() < 1e-6);
for (g, (ci, _)) in r.glyphs.iter().zip(&r.cells) {
let (x0, x1) = line.edges[*ci - line.start];
assert!(g.1 >= x0 - 0.01 && g.1 < x1, "glyph at {} outside its cell {x0}..{x1}", g.1);
}
}