Compare commits

...
Author SHA1 Message Date
NetterSilver ceb8011df8 Docs: update the progress docs for this change 2026-10-12 22:42:05 -04:00
NetterSilver 1124a96c1e Credit Silvertree2010 in the About window 2026-10-12 22:42:04 -04:00
NetterSilver 655ea46356 Render: a group's child-coordinate scale no longer scales line widths (#233)
Outlines, dashes, arrowheads and ink strokes were drawn under the full
transform, including the mapping of the group's child coordinate space onto its
box. A group whose children use small legacy child coordinates (scale ~1600)
therefore stroked 0.75 pt lines about 1200 pt wide and covered the slide. Inside
a group the outline is now carried through that mapping and stroked under the
slide-level view, so the line width, dash lengths and arrowhead sizes stay in
points, also for groups scaled unevenly.
2026-10-12 22:42:04 -04:00
6 changed files with 126 additions and 11 deletions
+2
View File
@@ -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 | 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 | 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 | 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-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-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 | | 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 | | 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 | 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 | 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 | | 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha |
+4
View File
@@ -37,3 +37,7 @@ sync_github_name = false
[people.Johannesgauss] [people.Johannesgauss]
sync_github_name = true sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
+54 -7
View File
@@ -238,11 +238,14 @@ pub struct Renderer {
/// What the frame being drawn has painted so far, in output pixels: the editor learns from it /// 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. /// how far a slide's drawing (text spilling out of its box too) reaches off the slide.
drawn: Option<Rect>, 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 { impl Default for Renderer {
fn default() -> Self { 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,25 @@ impl Renderer {
} }
fn shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) { 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) }
}
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) { if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) {
return; return;
} }
@@ -718,20 +740,34 @@ impl Renderer {
bp.line_to((*px, *py)); 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_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.set_stroke(kurbo::Stroke::new(sk.width.max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
ctx.stroke_path(&bp); 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 { if layered {
ctx.pop_layer(); 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) { let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) {
Some(pm) => morph_path::geometry(pm, s, w, h), Some(pm) => morph_path::geometry(pm, s, w, h),
None => (shape_geometry(s, w, h), Vec::new()), None => (shape_geometry(s, w, h), Vec::new()),
@@ -816,11 +852,19 @@ impl Renderer {
// Outline. // Outline.
if line.fill.as_ref().is_some_and(|f| !f.is_none()) { if line.fill.as_ref().is_some_and(|f| !f.is_none()) {
let lw = line.width.unwrap_or(0.75).max(0.0); 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) { 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() { 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 { if let Some(r) = soft {
@@ -1069,9 +1113,12 @@ fn end_points(path: &BezPath) -> Option<((Point, Point), (Point, Point))> {
Some(((first, second), (last, prev))) 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(sp) = geo.paths.iter().find(|p| p.stroke) else { return };
let Some(((a, a2), (b, b2))) = end_points(&sp.path) 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 { let col = match &line.fill {
Some(Fill::Solid { color: c }) => rctx.color(c, ph), Some(Fill::Solid { color: c }) => rctx.color(c, ph),
_ => Rgba::BLACK, _ => Rgba::BLACK,
+63
View File
@@ -1443,3 +1443,66 @@ fn metafile_pattern_brush_tiles_and_averages_when_tiny() {
let p = img.get_pixel(16, 16); let p = img.get_pixel(16, 16);
assert!((100..156).contains(&p[0]) && p[3] > 200, "average grey: {p:?}"); 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");
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity with PowerPoint # 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. 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** 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 | | 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 | | Notes slides and notes master | yes | yes | No notes-master editing view |
| Handout master | no | no | | | 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 | | | Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | |
| Lines: dash, compound, caps, joins, arrowheads | yes | yes | | | Lines: dash, compound, caps, joins, arrowheads | yes | yes | |
| Effects: shadow, glow, soft edges, reflection | 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 | 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 | 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 | | 2026-10-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues |
-2
View File
@@ -120,8 +120,6 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) | | 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 | 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-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 | | 2026-10-10 | major | Created: shell, panes and dialogs, direct manipulation, shortcuts and visual fidelity against PowerPoint 16.113 |