mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
351b2453b4 | ||
|
|
bb6f0dc88a | ||
|
|
c4f355773d | ||
|
|
d4bc8812a3 | ||
|
|
dc8a884389 |
@@ -134,6 +134,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-12 | Text keys like PowerPoint for Windows (#268): Ctrl+arrows and Ctrl+Shift+arrows by word (punctuation, CJK, emoji, RTL), Ctrl+Backspace/Delete with smart spacing, Ctrl+Up/Down by paragraph, Ctrl+Home/End, End on wrapped lines, Ctrl+]/[ and Ctrl+Shift+>/< font size, Shift+F3 case cycle, word and paragraph selection |
|
||||
| 2026-10-12 | EMF/WMF pictures draw their text (fonts, alignment, dx advances, escapement, symbol fonts), pattern and hatch brushes, PATBLT and the XOR/AND see-through shadow; legacy OLE objects show their VML preview and ActiveX controls their pictures |
|
||||
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
|
||||
| 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) |
|
||||
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
|
||||
@@ -145,6 +146,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
|
||||
| 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% |
|
||||
| 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha |
|
||||
|
||||
@@ -37,3 +37,7 @@ sync_github_name = false
|
||||
|
||||
[people.Johannesgauss]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
+107
-10
@@ -238,11 +238,14 @@ pub struct Renderer {
|
||||
/// What the frame being drawn has painted so far, in output pixels: the editor learns from it
|
||||
/// how far a slide's drawing (text spilling out of its box too) reaches off the slide.
|
||||
drawn: Option<Rect>,
|
||||
/// The transform a top-level shape is drawn under (slide to pixels); a group's children draw
|
||||
/// under it combined with the group's child-coordinate mapping, which must not scale lines.
|
||||
view: Affine,
|
||||
}
|
||||
|
||||
impl Default for Renderer {
|
||||
fn default() -> Self {
|
||||
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None, drawn: None }
|
||||
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None, drawn: None, view: Affine::IDENTITY }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,6 +663,43 @@ impl Renderer {
|
||||
}
|
||||
|
||||
fn shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) {
|
||||
if depth == 0 {
|
||||
let outer = std::mem::replace(&mut self.view, parent);
|
||||
self.shape_at(ctx, f, rctx, s, parent, depth);
|
||||
self.view = outer;
|
||||
} else {
|
||||
self.shape_at(ctx, f, rctx, s, parent, depth);
|
||||
}
|
||||
}
|
||||
|
||||
/// How lines are drawn for a shape under `m` at `depth`: the transform to stroke under and the
|
||||
/// one that takes the shape's outline into it. Inside a group they are the slide-level view and
|
||||
/// the rest of the chain, so the group's child-coordinate scale moves and sizes the outline but
|
||||
/// never the line width; outside a group, `m` itself and no change to the outline.
|
||||
fn line_space(&self, m: Affine, depth: usize) -> (Affine, Affine) {
|
||||
let view = self.view;
|
||||
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;
|
||||
}
|
||||
@@ -718,20 +758,34 @@ impl Renderer {
|
||||
bp.line_to((*px, *py));
|
||||
}
|
||||
}
|
||||
ctx.set_transform(parent);
|
||||
let (base, rel) = self.line_space(parent, depth);
|
||||
bp.apply_affine(rel);
|
||||
ctx.set_transform(base);
|
||||
ctx.set_paint(color(sk.color, if sk.highlighter { 0.4 } else { 1.0 }));
|
||||
ctx.set_stroke(kurbo::Stroke::new(sk.width.max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&bp);
|
||||
}
|
||||
}
|
||||
_ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st),
|
||||
_ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st, depth),
|
||||
}
|
||||
if layered {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
|
||||
fn geometry_shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, m: Affine, w: f64, h: f64, st: &ShapeState) {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn geometry_shape(
|
||||
&mut self,
|
||||
ctx: &mut RenderContext,
|
||||
f: &Frame,
|
||||
rctx: &Ctx,
|
||||
s: &Shape,
|
||||
m: Affine,
|
||||
w: f64,
|
||||
h: f64,
|
||||
st: &ShapeState,
|
||||
depth: usize,
|
||||
) {
|
||||
let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) {
|
||||
Some(pm) => morph_path::geometry(pm, s, w, h),
|
||||
None => (shape_geometry(s, w, h), Vec::new()),
|
||||
@@ -749,7 +803,12 @@ 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 {
|
||||
self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, m, w, h);
|
||||
// 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);
|
||||
}
|
||||
let soft = effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0);
|
||||
if let Some(r) = soft {
|
||||
@@ -816,11 +875,19 @@ impl Renderer {
|
||||
// Outline.
|
||||
if line.fill.as_ref().is_some_and(|f| !f.is_none()) {
|
||||
let lw = line.width.unwrap_or(0.75).max(0.0);
|
||||
let (base, rel) = self.line_space(m, depth);
|
||||
for (i, sp) in geo.paths.iter().enumerate().filter(|(i, p)| p.stroke && fade(*i).stroke > 0.0) {
|
||||
stroke(ctx, rctx, &line, line_ph, &sp.path, m, lw, fade(i).stroke);
|
||||
let path = if rel == Affine::IDENTITY {
|
||||
std::borrow::Cow::Borrowed(&sp.path)
|
||||
} else {
|
||||
let mut moved = sp.path.clone();
|
||||
moved.apply_affine(rel);
|
||||
std::borrow::Cow::Owned(moved)
|
||||
};
|
||||
stroke(ctx, rctx, &line, line_ph, &path, base, lw, fade(i).stroke);
|
||||
}
|
||||
if s.is_line() || geo.is_open() {
|
||||
arrowheads(ctx, rctx, &line, line_ph, &geo, m, lw);
|
||||
arrowheads(ctx, rctx, &line, line_ph, &geo, rel, base, lw);
|
||||
}
|
||||
}
|
||||
if let Some(r) = soft {
|
||||
@@ -872,6 +939,16 @@ 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 });
|
||||
@@ -915,6 +992,7 @@ impl Renderer {
|
||||
ph: Option<Rgba>,
|
||||
geo: &Geometry,
|
||||
fill: &Option<Fill>,
|
||||
content: Option<&deckcraft_model::style::PictureFill>,
|
||||
m: Affine,
|
||||
w: f64,
|
||||
h: f64,
|
||||
@@ -942,8 +1020,24 @@ 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);
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
ctx.fill_path(&silhouette);
|
||||
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.pop_layer();
|
||||
}
|
||||
if let Some(g) = &e.glow {
|
||||
@@ -1069,9 +1163,12 @@ fn end_points(path: &BezPath) -> Option<((Point, Point), (Point, Point))> {
|
||||
Some(((first, second), (last, prev)))
|
||||
}
|
||||
|
||||
fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, geo: &Geometry, m: Affine, lw: f64) {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, geo: &Geometry, rel: Affine, m: Affine, lw: f64) {
|
||||
let Some(sp) = geo.paths.iter().find(|p| p.stroke) else { return };
|
||||
let Some(((a, a2), (b, b2))) = end_points(&sp.path) else { return };
|
||||
// The outline's ends in the space the arrowheads are sized and drawn in.
|
||||
let (a, a2, b, b2) = (rel * a, rel * a2, rel * b, rel * b2);
|
||||
let col = match &line.fill {
|
||||
Some(Fill::Solid { color: c }) => rctx.color(c, ph),
|
||||
_ => Rgba::BLACK,
|
||||
|
||||
@@ -1443,3 +1443,143 @@ fn metafile_pattern_brush_tiles_and_averages_when_tiny() {
|
||||
let p = img.get_pixel(16, 16);
|
||||
assert!((100..156).contains(&p[0]) && p[3] > 200, "average grey: {p:?}");
|
||||
}
|
||||
|
||||
/// A 400 x 200 group at (100, 100) holding a dashed 4 pt rectangle and a 3 pt line with an
|
||||
/// arrowhead, placed in the group's child coordinates by `to_child` (slide point to child point).
|
||||
fn outlined_group(child: Xfrm, to_child: impl Fn(f64, f64) -> (f64, f64)) -> Image {
|
||||
use deckcraft_model::style::{Dash, LineEnd};
|
||||
let ink = |w: f64| deckcraft_model::style::Line { dash: None, ..deckcraft_model::style::Line::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0)), w) };
|
||||
let place = |x0: f64, y0: f64, x1: f64, y1: f64| {
|
||||
let ((a, b), (c, d)) = (to_child(x0, y0), to_child(x1, y1));
|
||||
Xfrm::new(a, b, c - a, d - b)
|
||||
};
|
||||
let rect = Shape {
|
||||
id: ShapeId(11),
|
||||
xfrm: Some(place(140.0, 120.0, 300.0, 200.0)),
|
||||
geom: Geom::preset("rect"),
|
||||
line: Some(deckcraft_model::style::Line { dash: Some(Dash::Preset { name: "dash".into() }), ..ink(4.0) }),
|
||||
..Default::default()
|
||||
};
|
||||
let end = LineEnd { kind: "triangle".into(), w: "lg".into(), len: "lg".into() };
|
||||
let line = Shape {
|
||||
id: ShapeId(12),
|
||||
xfrm: Some(Xfrm { flip_v: true, ..place(150.0, 170.0, 450.0, 260.0) }),
|
||||
geom: Geom::preset("line"),
|
||||
line: Some(deckcraft_model::style::Line { tail: Some(end), ..ink(3.0) }),
|
||||
..Default::default()
|
||||
};
|
||||
let pts: Vec<(f64, f64, f32)> = [(160.0, 240.0), (260.0, 285.0)].iter().map(|(x, y)| to_child(*x, *y)).map(|(x, y)| (x, y, 1.0)).collect();
|
||||
let ink = Shape {
|
||||
id: ShapeId(13),
|
||||
xfrm: Some(place(100.0, 100.0, 500.0, 300.0)),
|
||||
kind: ShapeKind::Ink {
|
||||
strokes: vec![deckcraft_model::InkStroke { points: pts, color: Rgba::rgb(200, 0, 0), width: 5.0, highlighter: false }],
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let group = Shape {
|
||||
id: ShapeId(10),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
|
||||
kind: ShapeKind::Group { children: vec![rect, line, ink], child },
|
||||
..Default::default()
|
||||
};
|
||||
render_slide(&deck_with(vec![group]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||||
}
|
||||
|
||||
fn inked(img: &Image) -> usize {
|
||||
img.pixels.as_chunks::<4>().0.iter().filter(|px| px[1] < 200).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_child_scale_does_not_scale_line_widths() {
|
||||
// Children laid out 1:1 with the slide are the reference.
|
||||
let reference = outlined_group(Xfrm::new(100.0, 100.0, 400.0, 200.0), |x, y| (x, y));
|
||||
let drawn = inked(&reference);
|
||||
assert!((500..20_000).contains(&drawn), "the reference draws thin lines: {drawn}");
|
||||
// The same drawing in a small legacy child space (scale 100), and one scaled unevenly
|
||||
// (100 across, 50 down): same outlines, same line widths, dashes and arrowhead.
|
||||
let uniform = outlined_group(Xfrm::new(1.0, 1.0, 4.0, 2.0), |x, y| (1.0 + (x - 100.0) / 100.0, 1.0 + (y - 100.0) / 100.0));
|
||||
let uneven = outlined_group(Xfrm::new(1.0, 1.0, 4.0, 4.0), |x, y| (1.0 + (x - 100.0) / 100.0, 1.0 + (y - 100.0) / 50.0));
|
||||
for (name, img) in [("uniform", &uniform), ("uneven", &uneven)] {
|
||||
let (n, d) = (inked(img), differing(&reference, img));
|
||||
assert!(n.abs_diff(drawn) * 20 < drawn, "{name}: {n} inked pixels against {drawn}");
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); previously major: created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested.
|
||||
Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready**
|
||||
@@ -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`) |
|
||||
| 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 | |
|
||||
@@ -101,4 +101,5 @@ the round trip even though DeckCraft doesn't model it.
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | EMF/WMF text and pattern brushes drawn; OLE (VML) and ActiveX previews read |
|
||||
| 2026-10-12 | minor | Text warps rendered; their adjust values read and written |
|
||||
| 2026-10-12 | trivial | Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233) |
|
||||
| 2026-10-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues |
|
||||
|
||||
@@ -120,8 +120,6 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) |
|
||||
|
||||
| 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |
|
||||
|
||||
| 2026-10-12 | minor | Text editing keys: Ctrl+arrows, Ctrl+Backspace/Delete, Ctrl+Up/Down, Home/End on wrapped lines, Shift+F3, font size keys, as observed in PowerPoint for Windows (#268) |
|
||||
| 2026-10-10 | major | Created: shell, panes and dialogs, direct manipulation, shortcuts and visual fidelity against PowerPoint 16.113 |
|
||||
|
||||
Reference in New Issue
Block a user