mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ceb8011df8 | ||
|
|
1124a96c1e | ||
|
|
655ea46356 |
@@ -387,16 +387,6 @@ pub struct Effects {
|
||||
pub bevel: Option<String>,
|
||||
/// Unmodelled 3-D XML kept for round-trip (`scene3d`, `sp3d`).
|
||||
pub raw3d: Option<String>,
|
||||
/// `a:fillOverlay`: a fill blended over the shape's fill (`blend`: `over`, `mult`, `screen`,
|
||||
/// `darken`, `lighten`). Only solid and gradient overlays are kept.
|
||||
pub fill_overlay: Option<FillOverlay>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FillOverlay {
|
||||
pub blend: String,
|
||||
pub fill: Fill,
|
||||
}
|
||||
|
||||
impl Effects {
|
||||
@@ -407,7 +397,6 @@ impl Effects {
|
||||
&& self.soft_edge.is_none()
|
||||
&& self.reflection.is_none()
|
||||
&& self.bevel.is_none()
|
||||
&& self.fill_overlay.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -257,14 +257,6 @@ pub fn effects(el: &El) -> Effects {
|
||||
"innerShdw" => fx.inner_shadow = Some(shadow(e, true)),
|
||||
"glow" => fx.glow = Some(Glow { color: color(e).unwrap_or(ColorRef::rgb(Rgba::BLACK)), radius: pt(e, "rad").unwrap_or(0.0).max(0.0) }),
|
||||
"softEdge" => fx.soft_edge = Some(pt(e, "rad").unwrap_or(0.0).max(0.0)),
|
||||
"fillOverlay" => {
|
||||
let fill = e.elements().find_map(|f| match f.local() {
|
||||
"solidFill" => color(f).map(|c| Fill::Solid { color: c }),
|
||||
"gradFill" => Some(Fill::Gradient(gradient(f))),
|
||||
_ => None,
|
||||
});
|
||||
fx.fill_overlay = fill.map(|fill| deckcraft_model::style::FillOverlay { blend: e.attr("blend").unwrap_or("over").to_string(), fill });
|
||||
}
|
||||
"reflection" => {
|
||||
fx.reflection = Some(Reflection {
|
||||
blur: pt(e, "blurRad").unwrap_or(0.0),
|
||||
|
||||
@@ -76,7 +76,40 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) {
|
||||
match f {
|
||||
Fill::None => w.empty0("a:noFill"),
|
||||
Fill::Solid { color: c } => solid(w, c),
|
||||
Fill::Gradient(g) => gradient(w, g),
|
||||
Fill::Gradient(g) => {
|
||||
w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" }));
|
||||
// No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill.
|
||||
let mut stops = g.stops.clone();
|
||||
if stops.len() == 1 {
|
||||
let mut s = stops[0].clone();
|
||||
s.pos = 1.0;
|
||||
stops.push(s);
|
||||
}
|
||||
if !stops.is_empty() {
|
||||
w.open0("a:gsLst");
|
||||
for s in &stops {
|
||||
w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0))));
|
||||
color(w, &s.color);
|
||||
w.close("a:gs");
|
||||
}
|
||||
w.close("a:gsLst");
|
||||
}
|
||||
match &g.shape {
|
||||
GradientShape::Linear { angle, scaled } => {
|
||||
w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" }))
|
||||
}
|
||||
GradientShape::Path { path, focus } => {
|
||||
let p = match path.as_str() {
|
||||
"rect" | "shape" | "circle" => path.as_str(),
|
||||
_ => "circle",
|
||||
};
|
||||
w.open("a:path", A::new().a("path", p));
|
||||
w.empty("a:fillToRect", A::new().a("l", pct(focus[0])).a("t", pct(focus[1])).a("r", pct(focus[2])).a("b", pct(focus[3])));
|
||||
w.close("a:path");
|
||||
}
|
||||
}
|
||||
w.close("a:gradFill");
|
||||
}
|
||||
Fill::Picture(pf) => blip_fill(w, x, o, "a:blipFill", pf),
|
||||
Fill::Pattern(p) => {
|
||||
w.open("a:pattFill", A::new().a("prst", if p.preset.is_empty() { "pct5" } else { &p.preset }));
|
||||
@@ -242,55 +275,8 @@ fn shadow(w: &mut W, tag: &str, s: &deckcraft_model::style::Shadow) {
|
||||
w.close(tag);
|
||||
}
|
||||
|
||||
fn gradient(w: &mut W, g: &deckcraft_model::style::Gradient) {
|
||||
w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" }));
|
||||
// No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill.
|
||||
let mut stops = g.stops.clone();
|
||||
if stops.len() == 1 {
|
||||
let mut s = stops[0].clone();
|
||||
s.pos = 1.0;
|
||||
stops.push(s);
|
||||
}
|
||||
if !stops.is_empty() {
|
||||
w.open0("a:gsLst");
|
||||
for s in &stops {
|
||||
w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0))));
|
||||
color(w, &s.color);
|
||||
w.close("a:gs");
|
||||
}
|
||||
w.close("a:gsLst");
|
||||
}
|
||||
match &g.shape {
|
||||
GradientShape::Linear { angle, scaled } => w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" })),
|
||||
GradientShape::Path { path, focus } => {
|
||||
let p = match path.as_str() {
|
||||
"rect" | "shape" | "circle" => path.as_str(),
|
||||
_ => "circle",
|
||||
};
|
||||
w.open("a:path", A::new().a("path", p));
|
||||
w.empty("a:fillToRect", A::new().a("l", pct(focus[0])).a("t", pct(focus[1])).a("r", pct(focus[2])).a("b", pct(focus[3])));
|
||||
w.close("a:path");
|
||||
}
|
||||
}
|
||||
w.close("a:gradFill");
|
||||
}
|
||||
|
||||
pub fn effects(w: &mut W, fx: &Effects) {
|
||||
w.open0("a:effectLst");
|
||||
// Schema order: blur, fillOverlay, glow, innerShdw, outerShdw, prstShdw, reflection, softEdge.
|
||||
if let Some(ov) = &fx.fill_overlay {
|
||||
let blend = match ov.blend.as_str() {
|
||||
b @ ("over" | "mult" | "screen" | "darken" | "lighten") => b,
|
||||
_ => "over",
|
||||
};
|
||||
w.open("a:fillOverlay", A::new().a("blend", blend));
|
||||
match &ov.fill {
|
||||
Fill::Solid { color: c } => solid(w, c),
|
||||
Fill::Gradient(g) => gradient(w, g),
|
||||
_ => w.empty0("a:noFill"),
|
||||
}
|
||||
w.close("a:fillOverlay");
|
||||
}
|
||||
if let Some(g) = &fx.glow {
|
||||
w.open("a:glow", A::new().a("rad", emu(g.radius).max(0)));
|
||||
color(w, &g.color);
|
||||
|
||||
@@ -102,34 +102,3 @@ fn a_gradient_without_stops_is_saved_without_stops() {
|
||||
let s = q.slides[0].shapes.iter().find(|s| s.name == "Styled").expect("shape");
|
||||
assert_eq!(s.fill, Some(Fill::Gradient(own)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_overlay_round_trips() {
|
||||
use deckcraft_model::style::{FillOverlay, Gradient, GradientShape, GradientStop};
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let id = ShapeId(p.alloc_id());
|
||||
let first = p.slides[0].id;
|
||||
let overlay = FillOverlay {
|
||||
blend: "mult".into(),
|
||||
fill: Fill::Gradient(Gradient {
|
||||
stops: vec![
|
||||
GradientStop { pos: 0.0, color: deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1) },
|
||||
GradientStop { pos: 1.0, color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(10, 20, 30)) },
|
||||
],
|
||||
shape: GradientShape::Linear { angle: 4.5, scaled: true },
|
||||
rotate_with_shape: true,
|
||||
}),
|
||||
};
|
||||
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
||||
id,
|
||||
name: "Overlaid".into(),
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)),
|
||||
fill: Some(Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::WHITE))),
|
||||
effects: Some(deckcraft_model::Effects { fill_overlay: Some(overlay.clone()), ..Default::default() }),
|
||||
..Default::default()
|
||||
});
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
let q = deckcraft_pptx::import(&bytes).expect("import");
|
||||
let s = q.slides[0].shapes.iter().find(|s| s.name == "Overlaid").expect("shape");
|
||||
assert_eq!(s.effects.as_ref().and_then(|e| e.fill_overlay.clone()), Some(overlay));
|
||||
}
|
||||
|
||||
@@ -681,24 +681,6 @@ impl Renderer {
|
||||
if depth > 0 && view.determinant().abs() > 1e-12 && view.is_finite() { (view, view.inverse() * m) } else { (m, Affine::IDENTITY) }
|
||||
}
|
||||
|
||||
/// The size and transform a shape's text is set in: inside a group whose child coordinates
|
||||
/// scale the shape, the shape's size in slide points and `m` with that scale taken out, so
|
||||
/// insets and font sizes stay in points; otherwise `w`, `h` and `m` themselves.
|
||||
fn text_space(&self, m: Affine, depth: usize, w: f64, h: f64) -> (f64, f64, Affine) {
|
||||
let (_, rel) = self.line_space(m, depth);
|
||||
let c = rel.as_coeffs();
|
||||
let (sx, sy) = (c[0].hypot(c[1]), c[2].hypot(c[3]));
|
||||
let ok = |k: f64| k.is_finite() && k > 1e-9;
|
||||
if depth == 0 || !ok(sx) || !ok(sy) || ((sx - 1.0).abs() < 1e-9 && (sy - 1.0).abs() < 1e-9) {
|
||||
return (w, h, m);
|
||||
}
|
||||
let (tw, th) = (w * sx, h * sy);
|
||||
if !tw.is_finite() || !th.is_finite() {
|
||||
return (w, h, m);
|
||||
}
|
||||
(tw, th, m * Affine::scale_non_uniform(1.0 / sx, 1.0 / sy))
|
||||
}
|
||||
|
||||
fn shape_at(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) {
|
||||
if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) {
|
||||
return;
|
||||
@@ -803,12 +785,7 @@ impl Renderer {
|
||||
let fill = if let Some(t) = st.tint { Some(Fill::solid(deckcraft_model::ColorRef::rgb(t))) } else { fill };
|
||||
// Effects below the shape.
|
||||
if let Some(e) = &effects {
|
||||
// A picture (or picture fill) casts its shadow from its pixels, not its frame.
|
||||
let content = match (&s.kind, &fill) {
|
||||
(ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => Some(paint::resolved(rctx, pf, None).into_owned()),
|
||||
_ => None,
|
||||
};
|
||||
self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, content.as_ref(), m, w, h);
|
||||
self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, m, w, h);
|
||||
}
|
||||
let soft = effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0);
|
||||
if let Some(r) = soft {
|
||||
@@ -872,25 +849,6 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
// `a:fillOverlay`: a fill blended over the shape's fill (PowerPoint's "Flow" themes multiply
|
||||
// a gradient over a tiled picture), inside the filled outline, under the line.
|
||||
if let Some(ov) = effects.as_ref().and_then(|e| e.fill_overlay.as_ref())
|
||||
&& fill.as_ref().is_some_and(|f| !f.is_none())
|
||||
{
|
||||
let mix = match ov.blend.as_str() {
|
||||
"mult" => peniko::Mix::Multiply,
|
||||
"screen" => peniko::Mix::Screen,
|
||||
"darken" => peniko::Mix::Darken,
|
||||
"lighten" => peniko::Mix::Lighten,
|
||||
_ => peniko::Mix::Normal,
|
||||
};
|
||||
for (i, sp) in geo.paths.iter().enumerate().filter(|(i, sp)| sp.fill != FillMode::None && fade(*i).fill > 0.0) {
|
||||
ctx.set_transform(m);
|
||||
ctx.push_layer(Some(&sp.path), Some(peniko::BlendMode::new(mix, peniko::Compose::SrcOver)), None, None, None);
|
||||
paint::fill_path(ctx, rctx, &ov.fill, fx_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, FillMode::Norm, fade(i).fill, f.pres);
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
// Outline.
|
||||
if line.fill.as_ref().is_some_and(|f| !f.is_none()) {
|
||||
let lw = line.width.unwrap_or(0.75).max(0.0);
|
||||
@@ -958,16 +916,6 @@ impl Renderer {
|
||||
if let Some(body) = &s.text
|
||||
&& !skip_text
|
||||
{
|
||||
// Text in a group is set at the shape's size on the slide, in points: a group's
|
||||
// child-coordinate scale sizes and places the box, never the type (#233's twin).
|
||||
let (tw, th, m) = self.text_space(m, depth, w, h);
|
||||
let text_geo;
|
||||
let geo = if (tw, th) == (w, h) {
|
||||
&geo
|
||||
} else {
|
||||
text_geo = shape_geometry(s, tw, th);
|
||||
&text_geo
|
||||
};
|
||||
let tr = geo.text_rect;
|
||||
if !body.is_empty() {
|
||||
let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
|
||||
@@ -1011,7 +959,6 @@ impl Renderer {
|
||||
ph: Option<Rgba>,
|
||||
geo: &Geometry,
|
||||
fill: &Option<Fill>,
|
||||
content: Option<&deckcraft_model::style::PictureFill>,
|
||||
m: Affine,
|
||||
w: f64,
|
||||
h: f64,
|
||||
@@ -1039,24 +986,8 @@ impl Renderer {
|
||||
// Blurred by a third of the blur radius, as PowerPoint's export shows.
|
||||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k)));
|
||||
ctx.set_transform(sm);
|
||||
match content {
|
||||
// As PowerPoint draws it: the shadow is the picture's own coverage (a logo on a
|
||||
// transparent background casts the logo's shadow): the picture, clipped to the
|
||||
// shape, recoloured to the shadow colour.
|
||||
Some(pf) => {
|
||||
ctx.push_clip_layer(&silhouette);
|
||||
paint::picture(ctx, rctx.pres, pf, Rect::new(0.0, 0.0, w, h), sm, None);
|
||||
ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None);
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h).inflate(1.0, 1.0));
|
||||
ctx.pop_layer();
|
||||
ctx.pop_layer();
|
||||
}
|
||||
None => {
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
ctx.fill_path(&silhouette);
|
||||
}
|
||||
}
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
ctx.fill_path(&silhouette);
|
||||
ctx.pop_layer();
|
||||
}
|
||||
if let Some(g) = &e.glow {
|
||||
|
||||
@@ -1506,111 +1506,3 @@ fn group_child_scale_does_not_scale_line_widths() {
|
||||
assert!(d * 20 < drawn, "{name}: {d} differing values against {drawn} inked pixels");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_casts_its_shadow_from_its_pixels() {
|
||||
// PowerPoint shades only where a picture paints: a logo on a transparent background casts the
|
||||
// logo's shadow, not its frame's. Here the left half of the image is transparent.
|
||||
let png = {
|
||||
let img = image::RgbaImage::from_fn(8, 4, |x, _| if x < 4 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([20, 160, 20, 255]) });
|
||||
let mut out = std::io::Cursor::new(vec![]);
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
};
|
||||
let shadow = deckcraft_model::style::Shadow {
|
||||
color: ColorRef::rgb(deckcraft_color::Rgba::BLACK),
|
||||
blur: 0.0,
|
||||
dist: 20.0,
|
||||
dir: 90.0,
|
||||
inner: false,
|
||||
sx: 1.0,
|
||||
sy: 1.0,
|
||||
kx: 0.0,
|
||||
ky: 0.0,
|
||||
align: String::new(),
|
||||
rotate_with_shape: false,
|
||||
};
|
||||
for as_fill in [false, true] {
|
||||
let mut p = Presentation::default();
|
||||
let media = p.add_media("logo.png", "image/png", png.clone());
|
||||
let pf = deckcraft_model::style::PictureFill { media, ..Default::default() };
|
||||
let mut sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), ..Default::default() };
|
||||
if as_fill {
|
||||
sh.fill = Some(Fill::Picture(pf));
|
||||
sh.line = Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() });
|
||||
} else {
|
||||
sh.kind = ShapeKind::Picture { fill: pf };
|
||||
}
|
||||
sh.effects = Some(deckcraft_model::Effects { outer_shadow: Some(shadow.clone()), ..Default::default() });
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
// Below the picture, 10 pt down: shadow under the opaque right half only.
|
||||
let left = img.pixel(200, 310);
|
||||
let right = img.pixel(400, 310);
|
||||
assert!(left[0] > 240, "no shadow under the transparent half ({as_fill}): {left:?}");
|
||||
assert!(right[0] < 40, "shadow under the opaque half ({as_fill}): {right:?}");
|
||||
// The transparent half shows the slide, not a shadow behind it.
|
||||
assert!(img.pixel(200, 200)[0] > 240, "{as_fill}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_in_a_scaled_group_keeps_its_point_size() {
|
||||
// A group's child coordinates scale its shapes' boxes, never their type: a 24 pt word in a
|
||||
// group whose child space is 1/1600 of its size (as old decks write them) or 4 times it is
|
||||
// 24 pt on the slide, and inside its box (it used to grow until the slide turned black).
|
||||
let draw = |scale: f64| {
|
||||
let mut p = Presentation::default();
|
||||
let mut body = TextBody::from_text("Hello");
|
||||
body.paragraphs[0].runs[0].props.size = Some(24.0);
|
||||
body.paragraphs[0].runs[0].props.fill = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::BLACK)));
|
||||
let child = Shape { id: ShapeId(601), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0 / scale, 200.0 / scale)), text: Some(body), ..Default::default() };
|
||||
let g = Shape {
|
||||
id: ShapeId(600),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
|
||||
kind: ShapeKind::Group { children: vec![child], child: Xfrm::new(0.0, 0.0, 400.0 / scale, 200.0 / scale) },
|
||||
..Default::default()
|
||||
};
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![g];
|
||||
render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||||
};
|
||||
let ((_, y0, _, y1), _) = ink(&draw(1.0));
|
||||
let plain = y1 - y0;
|
||||
assert!(plain > 10 && plain < 30, "24 pt word ink height {plain}");
|
||||
for scale in [1600.0, 4.0, 0.25] {
|
||||
let ((x0, sy0, x1, sy1), _) = ink(&draw(scale));
|
||||
assert!((sy1 - sy0).abs_diff(plain) <= 1, "scale {scale}: ink height {} vs {plain}", sy1 - sy0);
|
||||
assert!(x0 >= 100 && x1 <= 500 && sy0 >= 100 && sy1 <= 300, "scale {scale}: ink inside the box");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_overlay_blends_over_the_fill() {
|
||||
// `a:fillOverlay blend="mult"`: a 50% grey multiplied over a light fill darkens it; "screen"
|
||||
// lightens a dark one; the overlay stays inside the shape.
|
||||
use deckcraft_model::style::FillOverlay;
|
||||
let draw = |base: [u8; 3], blend: &str| {
|
||||
let mut p = Presentation::default();
|
||||
let mut sh = Shape {
|
||||
id: ShapeId(600),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
|
||||
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(base[0], base[1], base[2])))),
|
||||
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
sh.effects = Some(deckcraft_model::Effects {
|
||||
fill_overlay: Some(FillOverlay { blend: blend.into(), fill: Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(128, 128, 128))) }),
|
||||
..Default::default()
|
||||
});
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
(img.pixel(200, 150), img.pixel(50, 50))
|
||||
};
|
||||
let (mult, outside) = draw([200, 100, 50], "mult");
|
||||
assert!((mult[0] as i32 - 100).abs() <= 3 && (mult[1] as i32 - 50).abs() <= 3 && (mult[2] as i32 - 25).abs() <= 3, "{mult:?}");
|
||||
assert_eq!(&outside[..3], &[255, 255, 255], "nothing outside the shape");
|
||||
let (screen, _) = draw([0, 0, 0], "screen");
|
||||
assert!((screen[0] as i32 - 128).abs() <= 3, "{screen:?}");
|
||||
let (over, _) = draw([200, 100, 50], "over");
|
||||
assert!((over[0] as i32 - 128).abs() <= 3 && (over[1] as i32 - 128).abs() <= 3, "{over:?}");
|
||||
}
|
||||
|
||||
+3
-16
@@ -201,9 +201,6 @@ struct Style {
|
||||
/// The `a:ea` and `a:cs` families (theme references resolved), if the document names one.
|
||||
ea: Option<String>,
|
||||
cs: Option<String>,
|
||||
/// The `a:sym` family: private-use characters (U+F000–U+F0FF, as PowerPoint stores symbol-font
|
||||
/// characters) are drawn in it.
|
||||
sym: Option<String>,
|
||||
/// Heading font (`+mj-…`): the theme's major font set applies.
|
||||
major: bool,
|
||||
/// Pair kerning on: `a:rPr/@kern` is set and the size reaches it.
|
||||
@@ -318,7 +315,6 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
|
||||
lang: r.lang.clone().filter(|l| !l.is_empty()),
|
||||
ea,
|
||||
cs,
|
||||
sym: r.font_sym.as_deref().map(|f| ctx.master.theme.font(f)).filter(|f| !f.trim().is_empty()),
|
||||
major,
|
||||
kern: r.kern.is_some_and(|k| k > 0.0 && size + 1e-6 >= k * scale),
|
||||
}
|
||||
@@ -592,18 +588,9 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
|
||||
// Emoji, and pictographs asked to look like text (U+FE0E), pick their own face.
|
||||
let neutral =
|
||||
cluster.iter().all(|c| script::script_of(*c).is_none()) && !deckcraft_fonts::is_emoji_cluster(cluster) && !cluster.contains(&'\u{FE0E}');
|
||||
// A symbol-font character (U+F0xx) with an `a:sym` font draws in that font when it's there.
|
||||
let sym_face = st
|
||||
.sym
|
||||
.as_deref()
|
||||
.filter(|_| cluster.first().is_some_and(|c| ('\u{F000}'..='\u{F0FF}').contains(c)))
|
||||
.filter(|f| db.has_family(f))
|
||||
.map(|f| db.face(f, st.style_name))
|
||||
.filter(|f| deckcraft_fonts::covers_cluster(f, cluster));
|
||||
let face = match (sym_face, prev.as_ref()) {
|
||||
(Some(f), _) => f,
|
||||
(None, Some(p)) if neutral && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) => p.clone(),
|
||||
(None, _) => {
|
||||
let face = match prev.as_ref() {
|
||||
Some(p) if neutral && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) => p.clone(),
|
||||
_ => {
|
||||
let chain = chains.entry((si, slot, sc)).or_insert_with(|| face_chain(ctx, st, slot, sc));
|
||||
db.cascade(cluster, chain, st.style_name)
|
||||
}
|
||||
|
||||
@@ -759,25 +759,3 @@ fn symbol_char_maps_symbol_font_codes() {
|
||||
assert_eq!(symbol_char(&face, "Wingdings", '\u{F06E}'), Some('\u{25A0}'));
|
||||
assert_eq!(symbol_char(&face, "Wingdings", '\u{100}'), None, "not a symbol code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_font_characters_draw_in_the_sym_font() {
|
||||
// PowerPoint stores a Symbol-font character as U+F0xx with `<a:sym typeface="Symbol"/>`:
|
||||
// U+F0DE is the Symbol font's double arrow (⇒). It must come from that font, not from
|
||||
// whichever installed font happens to map U+F0DE (Wingdings draws an arrow at another angle).
|
||||
let symbol = "Symbol";
|
||||
if !FontDb::global().has_family(symbol) {
|
||||
eprintln!("skipped: no Symbol font installed");
|
||||
return;
|
||||
}
|
||||
let (p, mut sh) = setup("x\u{F0DE}y", 600.0, 300.0);
|
||||
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
para.props.bullet = Some(Bullet::None);
|
||||
para.runs[0].props.font = Some("Arial".into());
|
||||
para.runs[0].props.font_sym = Some(symbol.into());
|
||||
let l = lay(&p, &sh, 600.0, 300.0);
|
||||
let families: Vec<(String, (usize, usize))> = l.runs.iter().map(|r| (r.face.family.clone(), r.chars)).collect();
|
||||
assert!(families.iter().any(|(f, c)| f.eq_ignore_ascii_case(symbol) && c.0 == 1), "{families:?}");
|
||||
// The letters around it keep the run's font.
|
||||
assert!(families.iter().all(|(f, c)| c.0 == 1 || !f.eq_ignore_ascii_case(symbol)), "{families:?}");
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ the round trip even though DeckCraft doesn't model it.
|
||||
| Themes: colour, font (Latin/EA/CS), effect schemes, background styles | yes | yes | Unknown theme parts kept |
|
||||
| Notes slides and notes master | yes | yes | No notes-master editing view |
|
||||
| Handout master | no | no | |
|
||||
| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`). Outlines, dashes, arrowheads and ink inside a group, and text, keep their size in points whatever the group's child-coordinate scale (#233). |
|
||||
| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`). Outlines, dashes, arrowheads and ink inside a group keep their width in points whatever the group's child-coordinate scale (#233); text in a group with a tiny child space is not drawn yet. |
|
||||
| Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | |
|
||||
| Lines: dash, compound, caps, joins, arrowheads | yes | yes | |
|
||||
| Effects: shadow, glow, soft edges, reflection | yes | yes | |
|
||||
|
||||
Reference in New Issue
Block a user