mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Merge pull request #1260 from aiongg/r1-batch1
Integration batch 1: 35 contributor PRs (charts, shapes, slides, text editing, dialogs, outline files)
This commit is contained in:
+21
@@ -134,6 +134,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) |
|
||||
| 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) |
|
||||
| 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 |
|
||||
| 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) |
|
||||
@@ -145,7 +146,10 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-23 | Normal view: a slide zoomed past the window gets a horizontal and a vertical scroll bar on the axes that overflow; drag the thumb or click the track to reach every edge (#1157) |
|
||||
| 2026-10-22 | Shape Fill and Shape Outline format the members of a selected group, including nested groups, in one undo step. Fill transparency retains each member's effective colour (#1079) |
|
||||
| 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide |
|
||||
| 2026-10-21 | Groups nest at most 32 deep (`arrange.group` refuses more), as deep as PPTX import reads and slides draw: a deck grouped 39 or more levels deep saved without complaint and then could not be opened again |
|
||||
| 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions |
|
||||
| 2026-10-22 | Chart bars and their legend keys draw the series' own pattern fill (`a:pattFill`, now read and written) and draw nothing for `a:noFill`, with the series outline where it has one, as PowerPoint for Mac does (synthetic probe); they took the palette's colour before. Lines, areas and markers use a pattern's foreground colour |
|
||||
| 2026-10-21 | Reuse Slides: pick which slides of the other presentation to insert from a list (`slide.reuseList`, #1103) |
|
||||
| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
|
||||
| 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) |
|
||||
| 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) |
|
||||
@@ -158,14 +162,26 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-17 | While a placeholder's text is being edited, a click on a shape in front of it selects that shape instead of moving the caret, as in PowerPoint (#931) |
|
||||
| 2026-10-16 | Symbol-font characters: a run's U+F0xx symbol code (from `<a:sym typeface="Symbol"/>`) now draws from the installed Symbol face even when it has no private-use mapping — Apple's Symbol on macOS maps the glyphs to real Unicode, so the code is remapped to that character (the Adobe Symbol encoding) instead of falling to a look-alike. Bullets and metafile text take the same remap |
|
||||
| 2026-10-15 | Native saves keep an imported chart's embedded workbook, drawings and relationships; headless PPTX → edit → native save/reopen → PPTX tests verify the payloads and stale-workbook rejection |
|
||||
| 2026-10-18 | Charts: a series' own data-label setting is honoured — numbers PowerPoint shows on a graph no longer disappear, and a bare `c:showVal` means on (#868) |
|
||||
| 2026-10-15 | Render: quick styles render on empty placeholders — the fill gate now checks for style and line overrides too (#832) |
|
||||
| 2026-10-15 | Canvas: the resize cursor follows the handle's rotated diagonal — rotated shapes showed the wrong diagonal cursor (#745) |
|
||||
| 2026-10-20 | Tests: the Options dialog's Cancel, Esc and close button put the settings back the way they were (#878) |
|
||||
| 2026-10-20 | Tests: Ctrl+A in the slide sorter selects every slide, as in PowerPoint (#936) |
|
||||
| 2026-10-18 | Thumbnails: navigating slides with the arrow keys scrolls the thumbnail pane to keep the current slide visible (#917) |
|
||||
| 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions |
|
||||
| 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) |
|
||||
| 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute |
|
||||
| 2026-10-16 | Fitted canvas: discrete mouse-wheel steps navigate immediately, including rapid and batched input, while trackpad gestures retain their cooldown and zoomed slides still pan. |
|
||||
| 2026-10-16 | PPTX import: a paragraph build edited in PowerPoint (paragraphs with their own start, other shapes' effects in between, builds by 2nd-level paragraphs) takes PowerPoint's clicks in the show; it used to be rebuilt as one build, 34 clicks instead of 10 on the reporter's slide. Paragraph builds with sub-points save as PowerPoint writes them (#844) |
|
||||
| 2026-10-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) |
|
||||
| 2026-10-15 | A `.pptx` slide that can't be read is reported, not dropped in silence: `lostSlides` from `file.open`, a message and a repaired copy in the app, a warning from `deckcraft-cli` (#203) |
|
||||
| 2026-10-16 | AutoFormat as you type: ordinals (1st, 22nd, 103rd) get superscript suffixes after a space or punctuation; Tools ▸ AutoCorrect and `text.autocorrect {ordinals}` turn it off |
|
||||
| 2026-10-16 | A PowerPoint Show (`.ppsx`) opened from the command line or the file association starts its slide show, as in PowerPoint; File › Open still opens it for editing (#862) |
|
||||
| 2026-10-15 | Canvas: the Format and Animation Painter override the cursor to show the tool is armed (#830) |
|
||||
| 2026-10-15 | Edit Points: right-click a segment for Straight/Curved Segment, a point for Open/Close Path (`shape.segmentType`, `shape.openPath`, `shape.closePath`); Delete Segment and Add Point in the menu still to come |
|
||||
| 2026-10-15 | Design › Browse for Themes… with a `.pptx` or `.potx` brings its slide master and layouts, not just the theme: the master the current slide uses is replaced (a preserved one stays beside it), the template stretches to the deck's slide size, and each slide moves to the layout with the same name or kind and keeps its content. One undo step (#807) |
|
||||
| 2026-10-15 | Clicking the frame of a text box or placeholder selects the box whole, so it can be moved, duplicated or deleted; only a click inside puts the caret in the text (#787) |
|
||||
| 2026-10-15 | Review › Compare: Reject and Reject All drop revisions without applying them (`review.reject`); Accept All skips what was rejected |
|
||||
| 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) |
|
||||
| 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) |
|
||||
| 2026-10-14 | Empty bulleted and numbered paragraphs show gray markers while editing, including on first click and after Enter. Markers use the list style and stay out of slide shows and exports. |
|
||||
@@ -265,6 +281,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged |
|
||||
| 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged |
|
||||
| 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged |
|
||||
| 2026-10-21 | trivial | Progress log: Reuse Slides picker (#1103). Scores and milestone names unchanged |
|
||||
| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |
|
||||
| 2026-10-16 | minor | Symbol-font U+F0xx codes remap to the installed Symbol face's real Unicode (Apple Symbol on macOS) |
|
||||
| 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged |
|
||||
@@ -276,6 +293,10 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-16 | trivial | Progress log: responsive mouse-wheel navigation on the fitted canvas |
|
||||
| 2026-10-21 | trivial | Progress log: theme `spDef` anchor no longer applied to a file's own shapes |
|
||||
| 2026-10-21 | trivial | Progress log: 3-D sizes in scaled groups stay in points |
|
||||
| 2026-10-22 | trivial | Progress log: chart series pattern and no fills |
|
||||
| 2026-10-15 | trivial | Progress log: Browse for Themes applies a template's master and layouts (#807) |
|
||||
| 2026-10-15 | trivial | Progress log: Compare Reject and Reject All |
|
||||
| 2026-10-16 | trivial | Progress log: PPTX paragraph builds edited in PowerPoint keep their clicks (#844) |
|
||||
| 2026-10-14 | trivial | remote MCP checks reply IDs, envelopes and bounded newline framing (#718) |
|
||||
| 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing |
|
||||
| 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>DeckCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="deckcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<link data-trunk rel="icon" href="../../assets/app-icon/hicolor/32x32/apps/ai.storyteller.deckcraft.png" />
|
||||
<link data-trunk rel="icon" href="../../assets/app-icon/deckcraft.ico" />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
|
||||
#deckcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
|
||||
@@ -59,3 +59,6 @@ real_name = "Ryan C. Shoemaker"
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.GhostMitra]
|
||||
sync_github_name = true
|
||||
|
||||
+10
-13
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use deckcraft_model::anim::TextBuild;
|
||||
use deckcraft_model::anim::{BuildRole, TextBuild, paragraph_build_roles};
|
||||
use deckcraft_model::style::Fill;
|
||||
use deckcraft_model::{AnimClass, AnimStart, Animation, ColorScheme, Rgba, SchemeSlot, ShapeId, Slide, Xfrm};
|
||||
|
||||
@@ -12,7 +12,6 @@ use crate::effects::{EffectSpec, exit_base, motion_path};
|
||||
/// Delay between paragraphs of a by-paragraph build that isn't on click (seconds).
|
||||
pub const PARAGRAPH_STAGGER: f64 = 0.5;
|
||||
const MAX_EFFECTS: usize = 100_000;
|
||||
const MAX_PARAGRAPHS: usize = 1_000;
|
||||
const MAX_ITERATIONS: f64 = 1.0e6;
|
||||
|
||||
/// Which sequence an effect belongs to.
|
||||
@@ -198,17 +197,15 @@ impl Timeline {
|
||||
(None, TextBuild::ByParagraph) => {
|
||||
let mut v: Vec<Option<usize>> = vec![];
|
||||
if let Some(t) = shape.and_then(|s| s.text.as_ref()) {
|
||||
let filled = |p: &deckcraft_model::text::Paragraph| p.runs.iter().any(|r| !r.text.trim().is_empty());
|
||||
let top = t.paragraphs.iter().filter(|p| filled(p)).map(|p| p.level).min().unwrap_or(0);
|
||||
let mut owner: Option<usize> = None;
|
||||
for (i, p) in t.paragraphs.iter().enumerate() {
|
||||
if p.level <= top && filled(p) && v.len() < MAX_PARAGRAPHS {
|
||||
owner = Some(i);
|
||||
v.push(Some(i));
|
||||
} else if let Some(o) = owner
|
||||
&& grouped.insert((a.shape, i, o))
|
||||
{
|
||||
tl.groups.push((a.shape, i, o));
|
||||
for (i, role) in paragraph_build_roles(t).into_iter().enumerate() {
|
||||
match role {
|
||||
BuildRole::Top => v.push(Some(i)),
|
||||
BuildRole::Member(o) => {
|
||||
if grouped.insert((a.shape, i, o)) {
|
||||
tl.groups.push((a.shape, i, o));
|
||||
}
|
||||
}
|
||||
BuildRole::Unowned => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +171,22 @@ pub(crate) fn union_bounds(xs: &[Xfrm]) -> Option<Rect> {
|
||||
Some(it.fold(first, |a, b| a.union(b)))
|
||||
}
|
||||
|
||||
/// How deep groups may nest: as deep as the PPTX reader reads and the renderer draws. A saved
|
||||
/// .deckcraft file stops opening at about 39 (the JSON reader's recursion limit), and walks over
|
||||
/// the shape tree stop at 64.
|
||||
pub const MAX_GROUP_DEPTH: usize = 32;
|
||||
|
||||
/// How many groups deep `shapes` nest (0 without groups).
|
||||
fn group_depth(shapes: &[Shape]) -> usize {
|
||||
let mut d = 0;
|
||||
deckcraft_model::walk(shapes, &mut |sh, depth| {
|
||||
if sh.is_group() {
|
||||
d = d.max(depth + 1);
|
||||
}
|
||||
});
|
||||
d
|
||||
}
|
||||
|
||||
fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
s.edit(|doc, sel| {
|
||||
@@ -179,6 +195,9 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if members.len() < 2 {
|
||||
return Err(bad("arrange.group", "select two or more objects on the slide"));
|
||||
}
|
||||
if group_depth(&members) + 1 > MAX_GROUP_DEPTH {
|
||||
return Err(bad("arrange.group", format!("groups can be nested at most {MAX_GROUP_DEPTH} deep")));
|
||||
}
|
||||
for m in &mut members {
|
||||
if m.xfrm.is_none() {
|
||||
m.xfrm = Some(xfrm_of(doc, sel, m));
|
||||
@@ -322,9 +341,9 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (start, end) = if to_slide {
|
||||
if horiz { (0.0, size.width) } else { (0.0, size.height) }
|
||||
} else {
|
||||
let f = boxes.first().map(|b| b.1).unwrap_or_default();
|
||||
let l = boxes.last().map(|b| b.1).unwrap_or_default();
|
||||
if horiz { (f.x0, l.x1) } else { (f.y0, l.y1) }
|
||||
let lo = |f: fn(&Rect) -> f64| boxes.iter().map(|b| f(&b.1)).fold(f64::INFINITY, f64::min);
|
||||
let hi = |f: fn(&Rect) -> f64| boxes.iter().map(|b| f(&b.1)).fold(f64::NEG_INFINITY, f64::max);
|
||||
if horiz { (lo(|r| r.x0), hi(|r| r.x1)) } else { (lo(|r| r.y0), hi(|r| r.y1)) }
|
||||
};
|
||||
let n = boxes.len();
|
||||
let gap = if n > 1 { ((end - start) - total) / (n - 1) as f64 } else { 0.0 };
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Browse for Themes…",
|
||||
["Design", "Themes"],
|
||||
None,
|
||||
"{path} | {name, data: base64} — apply the theme of a .thmx, .pptx or .potx file",
|
||||
"{path} | {name, data: base64} — apply a .pptx or .potx's slide master and layouts (slides move to the matching layouts), or a .thmx's theme",
|
||||
has_doc,
|
||||
browse_theme
|
||||
),
|
||||
@@ -161,6 +161,13 @@ fn browse_theme(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !deckcraft_pptx::sniff(&bytes) {
|
||||
return Err(bad("design.browseTheme", "not a theme or PowerPoint file"));
|
||||
}
|
||||
// A presentation or template brings its slide master and layouts, as in PowerPoint; a .thmx
|
||||
// (its main part is a theme, not a presentation) brings the theme alone.
|
||||
if let Ok(src) = super::file::open_presentation("theme.pptx", &bytes)
|
||||
&& src.masters.first().is_some_and(|m| !m.layouts.is_empty())
|
||||
{
|
||||
return apply_master(s, &src);
|
||||
}
|
||||
let (theme, media) = deckcraft_pptx::import_theme(&bytes).map_err(|e| bad("design.browseTheme", e.to_string()))?;
|
||||
let name = theme.name.clone();
|
||||
s.edit(|doc, sel| {
|
||||
@@ -180,6 +187,58 @@ fn browse_theme(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"theme": name}))
|
||||
}
|
||||
|
||||
/// Design › Browse for Themes… with a `.pptx` / `.potx`: its first master and layouts replace the
|
||||
/// master the current slide uses (a preserved master stays beside it), stretched to this deck's
|
||||
/// slide size. Each slide on it moves to the new layout with the same name, else the same kind,
|
||||
/// keeping its content as Layout does.
|
||||
fn apply_master(s: &mut Session, src: &Presentation) -> Result<Value> {
|
||||
let from = src.masters.first().ok_or_else(|| bad("design.browseTheme", "no master"))?;
|
||||
let theme = from.theme.name.clone();
|
||||
s.edit(|doc, sel| {
|
||||
let mi = master_index(doc, sel);
|
||||
let old = doc.masters.get(mi).cloned().ok_or_else(|| bad("design.browseTheme", "no master"))?;
|
||||
let (mut media, mut layouts) = Default::default();
|
||||
let mut m = super::reuse::import_master("design.browseTheme", doc, src, from, &mut media, &mut layouts)?;
|
||||
let (sx, sy) = (doc.slide_size.width / src.slide_size.width.max(1.0), doc.slide_size.height / src.slide_size.height.max(1.0));
|
||||
if (sx - 1.0).abs() > 1e-9 || (sy - 1.0).abs() > 1e-9 {
|
||||
scale_shapes(&mut m.shapes, sx, sy, 1.0, 0.0, 0.0);
|
||||
for l in &mut m.layouts {
|
||||
scale_shapes(&mut l.shapes, sx, sy, 1.0, 0.0, 0.0);
|
||||
}
|
||||
}
|
||||
let moves: Vec<(usize, deckcraft_model::LayoutId)> =
|
||||
doc.slides.iter().enumerate().filter_map(|(i, sl)| old.layout(sl.layout).and_then(|l| matching_layout(&m, l)).map(|l| (i, l))).collect();
|
||||
let layouts = m.layouts.len();
|
||||
if old.preserve {
|
||||
doc.masters.insert(mi, Arc::new(m));
|
||||
} else if let Some(slot) = doc.masters.get_mut(mi) {
|
||||
*slot = Arc::new(m);
|
||||
}
|
||||
for (i, l) in &moves {
|
||||
super::slide::apply_layout(doc, *i, *l)?;
|
||||
}
|
||||
if let Target::Layout { master, .. } = sel.target {
|
||||
sel.target = Target::Master { master };
|
||||
}
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
Ok(json!({"theme": theme, "layouts": layouts, "slides": moves.len()}))
|
||||
})
|
||||
}
|
||||
|
||||
/// The layout of `m` a slide on `from` moves to: same name, else same kind (not for custom
|
||||
/// layouts), else Title and Content, else the first.
|
||||
fn matching_layout(m: &deckcraft_model::Master, from: &deckcraft_model::Layout) -> Option<deckcraft_model::LayoutId> {
|
||||
use deckcraft_model::LayoutType;
|
||||
let ls = &m.layouts;
|
||||
ls.iter()
|
||||
.find(|l| !from.name.is_empty() && l.name.trim().eq_ignore_ascii_case(from.name.trim()))
|
||||
.or_else(|| ls.iter().find(|l| from.kind != LayoutType::Custom && l.kind == from.kind))
|
||||
.or_else(|| ls.iter().find(|l| l.kind == LayoutType::TitleAndContent))
|
||||
.or_else(|| ls.first())
|
||||
.map(|l| l.id)
|
||||
}
|
||||
|
||||
/// `theme` with every `media` id field rewritten through `map` (format-scheme picture fills).
|
||||
fn remap_media_ids(theme: &deckcraft_model::theme::Theme, map: &std::collections::HashMap<u32, u32>) -> Result<deckcraft_model::theme::Theme> {
|
||||
fn rec(v: &mut Value, map: &std::collections::HashMap<u32, u32>, depth: usize) {
|
||||
@@ -854,6 +913,14 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = str_param(p, "dateText") {
|
||||
hf.date_text = v.into();
|
||||
}
|
||||
if let Some(v) = str_param(p, "dateFormat") {
|
||||
// Refused rather than ignored: a format that renders nothing would leave the
|
||||
// placeholder showing its saved text for ever.
|
||||
if !deckcraft_text::datetime::DATE_KINDS.contains(&v) {
|
||||
return Err(bad("design.headerFooter", "dateFormat: one of datetime1…datetime13"));
|
||||
}
|
||||
hf.date_format = v.into();
|
||||
}
|
||||
if let Some(v) = bool_param(p, "slideNumber") {
|
||||
hf.slide_number = v;
|
||||
}
|
||||
@@ -869,6 +936,7 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let hf = hf.clone();
|
||||
let date_given = str_param(p, "dateText").is_some();
|
||||
let format_given = str_param(p, "dateFormat").is_some();
|
||||
let all = bool_or(p, "all", true);
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![sel.slide] };
|
||||
for i in list {
|
||||
@@ -913,7 +981,11 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
},
|
||||
PhType::Date if hf.date_text.is_empty() => TextBody {
|
||||
paragraphs: vec![deckcraft_model::Paragraph {
|
||||
runs: vec![Run { text: today(), props: Default::default(), kind: RunKind::Field { field: "datetime1".into() } }],
|
||||
runs: vec![Run {
|
||||
text: date_now(&hf.date_format),
|
||||
props: Default::default(),
|
||||
kind: RunKind::Field { field: date_kind(&hf.date_format).into() },
|
||||
}],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
@@ -934,9 +1006,9 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sh.text = Some(TextBody::from_text(&hf.footer_text));
|
||||
}
|
||||
}
|
||||
(Some(pos), true) if k == PhType::Date && date_given => {
|
||||
(Some(pos), true) if k == PhType::Date && (date_given || format_given) => {
|
||||
if let Some(sh) = sl.shapes.get_mut(pos) {
|
||||
set_date(sh, &hf.date_text);
|
||||
set_date(sh, &hf.date_text, &hf.date_format);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -948,15 +1020,33 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The date field type `format` names, `datetime1` when it is empty or unknown.
|
||||
fn date_kind(format: &str) -> &str {
|
||||
if deckcraft_text::datetime::DATE_KINDS.contains(&format) { format } else { "datetime1" }
|
||||
}
|
||||
|
||||
/// The saved text of a date field of `format`, as it shows now.
|
||||
fn date_now(format: &str) -> String {
|
||||
let kind = date_kind(format);
|
||||
deckcraft_text::datetime::DateTime::now_local().and_then(|now| deckcraft_text::datetime::field_text(kind, None, now)).unwrap_or_else(today)
|
||||
}
|
||||
|
||||
/// Point a slide's date placeholder at a fixed date (its own plain text) or, for an empty
|
||||
/// `fixed`, at an automatic date field (one it already has keeps its format).
|
||||
fn set_date(sh: &mut Shape, fixed: &str) {
|
||||
fn set_date(sh: &mut Shape, fixed: &str, format: &str) {
|
||||
let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs);
|
||||
let has_field = runs().any(|r| matches!(&r.kind, RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field)));
|
||||
let field_now = runs().find_map(|r| match &r.kind {
|
||||
RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field) => Some(field.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let has_field = field_now.is_some();
|
||||
let props = runs().next().map(|r| r.props.clone()).unwrap_or_default();
|
||||
let want = date_kind(format);
|
||||
let (text, kind) = match fixed {
|
||||
"" if has_field => return,
|
||||
"" => (today(), RunKind::Field { field: "datetime1".into() }),
|
||||
// Automatic: keep the field, but follow a format that has changed.
|
||||
"" if field_now.as_deref() == Some(want) => return,
|
||||
"" if has_field && format.is_empty() => return,
|
||||
"" => (date_now(format), RunKind::Field { field: want.into() }),
|
||||
_ if !has_field && sh.text.as_ref().is_some_and(|t| t.text() == fixed) => return,
|
||||
_ => (fixed.to_string(), RunKind::Text),
|
||||
};
|
||||
@@ -1955,8 +2045,10 @@ mod tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod browse_theme_tests {
|
||||
use deckcraft_model::PhType;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::RunKind;
|
||||
use deckcraft_model::{Background, Fill, PhType};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Session, Target};
|
||||
@@ -1982,6 +2074,108 @@ mod browse_theme_tests {
|
||||
assert!(s.execute("design.browseTheme", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// A 4:3 brand template: a logo and a background on the master, its layouts renamed (so slides
|
||||
/// can only find them by kind) and its content layout's title moved.
|
||||
fn brand_template() -> (String, Vec<u8>, deckcraft_model::Xfrm) {
|
||||
let mut img = image::RgbaImage::new(2, 2);
|
||||
img.put_pixel(0, 0, image::Rgba([200, 20, 30, 255]));
|
||||
let mut logo = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut logo), image::ImageFormat::Png).unwrap();
|
||||
let mut t = Session::with_new();
|
||||
t.execute("design.slideSize", &json!({"preset": "standard", "scale": "none"})).unwrap();
|
||||
let title = t
|
||||
.edit(|doc, _| {
|
||||
let media = doc.add_media("logo.png", "image/png", logo.clone()).unwrap();
|
||||
let id = deckcraft_model::ShapeId(doc.alloc_id().unwrap());
|
||||
let m = Arc::make_mut(doc.masters.get_mut(0).unwrap());
|
||||
m.shapes.push(deckcraft_model::Shape {
|
||||
id,
|
||||
name: "Logo".into(),
|
||||
kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } },
|
||||
xfrm: Some(deckcraft_model::Xfrm { x: 600.0, y: 20.0, w: 100.0, h: 40.0, ..Default::default() }),
|
||||
..Default::default()
|
||||
});
|
||||
m.background =
|
||||
Some(Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x0A, 0x2B, 0x4C))) });
|
||||
for l in &mut m.layouts {
|
||||
l.name = format!("Brand {}", l.name);
|
||||
}
|
||||
let obj = m.layouts.iter_mut().find(|l| l.kind == deckcraft_model::LayoutType::TitleAndContent).unwrap();
|
||||
let title = obj.shapes.iter_mut().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
|
||||
let x = deckcraft_model::Xfrm { x: 72.0, y: 400.0, w: 576.0, h: 80.0, ..Default::default() };
|
||||
title.xfrm = Some(x);
|
||||
Ok(x)
|
||||
})
|
||||
.unwrap();
|
||||
(crate::cmd::base64_encode(&deckcraft_pptx::export_as(&t.doc().unwrap().doc, "potx").unwrap()), logo, title)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_template_brings_its_master_and_layouts_and_slides_follow() {
|
||||
let (data, logo, title) = brand_template();
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "Point one"})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("design.browseTheme", &json!({"name": "Brand.potx", "data": data})).unwrap();
|
||||
assert_eq!(r["slides"], json!(2), "{r}");
|
||||
assert_eq!(undo_len(&s), n + 1, "one undo step");
|
||||
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
|
||||
assert_eq!(doc.masters.len(), 1, "the old master is replaced");
|
||||
let m = &doc.masters[0];
|
||||
assert_ne!(m.id, before.masters[0].id);
|
||||
assert!(m.layouts.iter().all(|l| l.name.starts_with("Brand ")));
|
||||
assert!(matches!(m.background, Some(Background::Fill { fill: Fill::Solid { .. } })));
|
||||
let pic = m.shapes.iter().find(|sh| sh.name == "Logo").expect("the master's logo comes along");
|
||||
let deckcraft_model::ShapeKind::Picture { fill } = &pic.kind else { panic!("logo is a picture") };
|
||||
assert_eq!(doc.media(fill.media).map(|m| m.data.as_ref().clone()), Some(logo));
|
||||
// The 4:3 master stretches to this deck's 16:9.
|
||||
let k = doc.slide_size.width / 720.0;
|
||||
let px = pic.xfrm.unwrap();
|
||||
assert!((px.x - 600.0 * k).abs() < 0.01 && (px.w - 100.0 * k).abs() < 0.01 && (px.y - 20.0).abs() < 0.01, "{px:?}");
|
||||
|
||||
// Each slide moved to the brand layout of its kind, and kept its text.
|
||||
for (old, new) in before.slides.iter().zip(&doc.slides) {
|
||||
let (_, was) = before.layout(old.layout).unwrap();
|
||||
let (owner, now) = doc.layout(new.layout).expect("on a layout of the new master");
|
||||
assert_eq!(owner.id, m.id);
|
||||
assert_eq!(now.kind, was.kind);
|
||||
}
|
||||
let agenda = doc.slides.iter().find(|sl| sl.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Agenda"))).unwrap();
|
||||
assert!(agenda.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Point one")));
|
||||
let (_, lay) = doc.layout(agenda.layout).unwrap();
|
||||
let lt = lay.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap().xfrm.unwrap();
|
||||
assert!((lt.x - title.x * k).abs() < 0.01 && (lt.y - title.y).abs() < 0.01, "{lt:?}");
|
||||
let st = agenda.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
|
||||
assert!(st.xfrm.is_none(), "the slide title follows the new layout");
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!(doc.masters, before.masters);
|
||||
assert_eq!(doc.slides, before.slides);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preserved_master_stays_beside_the_template() {
|
||||
let (data, _, _) = brand_template();
|
||||
let mut s = Session::with_new();
|
||||
s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap();
|
||||
s.execute("master.preserve", &json!({"preserve": true})).unwrap();
|
||||
s.execute("view.slideMaster", &json!({"master": 0})).unwrap();
|
||||
s.execute("master.preserve", &json!({"preserve": true})).unwrap();
|
||||
let old = s.doc().unwrap().doc.masters[0].id;
|
||||
s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap();
|
||||
s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc.masters.len(), 2);
|
||||
assert_eq!(st.doc.masters[1].id, old);
|
||||
assert_eq!(st.selection.target, Target::Master { master: 0 }, "Slide Master view shows the new master");
|
||||
let new = &st.doc.masters[0];
|
||||
assert!(st.doc.slides.iter().all(|sl| new.layout(sl.layout).is_some()));
|
||||
}
|
||||
|
||||
fn undo_len(s: &crate::Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
@@ -2132,3 +2326,90 @@ mod browse_theme_tests {
|
||||
assert!((box_.w - 840.0).abs() < 0.01 && (box_.h - 104.0).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod header_footer_date_tests {
|
||||
use deckcraft_model::PhType;
|
||||
use deckcraft_model::text::RunKind;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// The date field of the Date placeholders of every slide: (saved text, field type).
|
||||
fn dates(s: &Session) -> Vec<(String, Option<String>)> {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
d.slides
|
||||
.iter()
|
||||
.flat_map(|sl| sl.shapes.iter())
|
||||
.filter(|sh| sh.ph_type() == Some(PhType::Date))
|
||||
.flat_map(|sh| sh.text.iter())
|
||||
.flat_map(|t| t.paragraphs.iter())
|
||||
.flat_map(|p| p.runs.iter())
|
||||
.map(|r| {
|
||||
let kind = match &r.kind {
|
||||
RunKind::Field { field } => Some(field.clone()),
|
||||
_ => None,
|
||||
};
|
||||
(r.text.clone(), kind)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sample(kind: &str) -> String {
|
||||
let now = deckcraft_text::datetime::DateTime::now_local().expect("a local date");
|
||||
deckcraft_text::datetime::field_text(kind, None, now).expect("a sample")
|
||||
}
|
||||
|
||||
/// Runs `design.headerFooter` and checks the slide's date run is an automatic `kind` field.
|
||||
/// The clock is read before and after the command, so a second ticking over between the
|
||||
/// command and the read cannot fail the comparison. Returns the run text.
|
||||
fn run_automatic(s: &mut Session, params: serde_json::Value, kind: &str) -> String {
|
||||
let before = sample(kind);
|
||||
s.execute("design.headerFooter", ¶ms).unwrap();
|
||||
let after = sample(kind);
|
||||
let got = dates(s);
|
||||
assert_eq!(got.len(), 1, "{got:?}");
|
||||
assert_eq!(got[0].1.as_deref(), Some(kind));
|
||||
assert!(got[0].0 == before || got[0].0 == after, "{got:?} is neither {before:?} nor {after:?}");
|
||||
got[0].0.clone()
|
||||
}
|
||||
|
||||
/// Header & Footer's automatic date takes the format it is given. `HeaderFooter::date_format`
|
||||
/// existed for this and was never read: the placeholder was always `datetime1`, and a
|
||||
/// `dateFormat` parameter was accepted and silently dropped.
|
||||
#[test]
|
||||
fn the_automatic_date_takes_the_format_it_is_given() {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
// Default: the short date, as before.
|
||||
run_automatic(&mut s, json!({"date": true, "dateText": "", "all": true}), "datetime1");
|
||||
|
||||
// A format: the field type changes and the saved text is that format's.
|
||||
run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime5", "all": true}), "datetime5");
|
||||
assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime5");
|
||||
|
||||
// A time format works the same way, and these two differ, so the format is really read.
|
||||
let time = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11");
|
||||
assert_ne!(time, sample("datetime5"));
|
||||
|
||||
// Asking again changes nothing (and does not rewrite the run).
|
||||
let again = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11");
|
||||
assert_eq!(again, time);
|
||||
|
||||
// A fixed date still wins: plain text, no field.
|
||||
s.execute("design.headerFooter", &json!({"date": true, "dateText": "4 July 1776", "all": true})).unwrap();
|
||||
assert_eq!(dates(&s), vec![("4 July 1776".to_string(), None)]);
|
||||
|
||||
// Back to automatic: the remembered format comes back, not datetime1.
|
||||
run_automatic(&mut s, json!({"date": true, "dateText": "", "dateFormat": "datetime11", "all": true}), "datetime11");
|
||||
|
||||
// An unknown format is refused rather than ignored, and changes nothing.
|
||||
for bad in ["datetime0", "datetime14", "nonsense", ""] {
|
||||
assert!(s.execute("design.headerFooter", &json!({"date": true, "dateFormat": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime11");
|
||||
let got = dates(&s);
|
||||
assert_eq!(got.len(), 1, "{got:?}");
|
||||
assert_eq!(got[0].1.as_deref(), Some("datetime11"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as),
|
||||
cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as),
|
||||
cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, transparentBackground?: bool (png: leave the slide background out), width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|rtf|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, transparentBackground?: bool (png: leave the slide background out), width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export),
|
||||
cmd!(
|
||||
query "file.info",
|
||||
"Info",
|
||||
@@ -167,8 +167,10 @@ fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
|
||||
return Err(EngineError::Other(format!("{name} is password-protected. DeckCraft can't open password-protected presentations yet.")));
|
||||
}
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if lower.ends_with(".txt") || lower.ends_with(".md") {
|
||||
let rtf = deckcraft_format::rtf::sniff_rtf(bytes);
|
||||
if rtf || lower.ends_with(".txt") || lower.ends_with(".md") {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let text = if rtf { std::borrow::Cow::Owned(deckcraft_format::rtf::rtf_to_outline(&text)) } else { text };
|
||||
let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
|
||||
if lower.ends_with(".md") {
|
||||
deckcraft_format::markdown_to_slides(&mut p, &text);
|
||||
@@ -219,6 +221,12 @@ fn probe_clips(p: &mut Presentation) {
|
||||
}
|
||||
}
|
||||
|
||||
/// An outline file's text: RTF is read into the tab-indented outline, anything else as UTF-8.
|
||||
pub fn outline_text(bytes: &[u8]) -> String {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
if deckcraft_format::rtf::sniff_rtf(bytes) { deckcraft_format::rtf::rtf_to_outline(&text) } else { text.into_owned() }
|
||||
}
|
||||
|
||||
pub fn add_opened(s: &mut Session, name: &str, path: Option<String>, p: Presentation) -> usize {
|
||||
let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
|
||||
crate::fonts::install_embedded_fonts(&p);
|
||||
@@ -334,6 +342,7 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
|
||||
match format {
|
||||
f @ ("pptx" | "potx" | "ppsx") => deckcraft_pptx::export_as(doc, f).map_err(|e| EngineError::Other(e.to_string())),
|
||||
"outline" | "txt" => Ok(deckcraft_format::slides_to_outline(doc).into_bytes()),
|
||||
"rtf" => Ok(deckcraft_format::rtf::slides_to_rtf(doc).into_bytes()),
|
||||
"pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())),
|
||||
"gif" => gif_bytes(doc, &Value::Null),
|
||||
_ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())),
|
||||
@@ -409,7 +418,7 @@ pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
|
||||
}
|
||||
|
||||
/// The output extensions [`format_for_path`] maps to a writer of that format.
|
||||
pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "gif", "txt", "md"];
|
||||
pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "gif", "txt", "md", "rtf"];
|
||||
|
||||
/// The format a file named `path` is written in, or `None` when its extension names no format
|
||||
/// DeckCraft writes (`.odp`, `.svg`, no extension …): [`format_for_path`] would save
|
||||
@@ -442,6 +451,8 @@ pub fn format_for_path(path: &str) -> &'static str {
|
||||
"gif"
|
||||
} else if l.ends_with(".txt") || l.ends_with(".md") {
|
||||
"outline"
|
||||
} else if l.ends_with(".rtf") {
|
||||
"rtf"
|
||||
} else {
|
||||
"deckcraft"
|
||||
}
|
||||
@@ -622,7 +633,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"secondsPerSlide": f64::from(out.delay_cs) / 100.0,
|
||||
}))
|
||||
}
|
||||
"pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" => {
|
||||
"pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" | "rtf" => {
|
||||
let bytes = save_bytes(&st.doc, &format)?;
|
||||
write_file(&path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use deckcraft_model::edit as ed;
|
||||
use deckcraft_model::resolve;
|
||||
use deckcraft_model::text::{Align, Anchor, AutoFit, Bullet, Caps, ParaProps, RunProps, Spacing, Strike, TabStop, TextDir};
|
||||
use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, Shape};
|
||||
use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, Shape, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::text::{format_body, format_para, format_run};
|
||||
@@ -340,6 +340,27 @@ fn shrink(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
}
|
||||
fn color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = color_param(p, "color").ok_or_else(|| bad("format.color", "missing or invalid `color`"))?;
|
||||
// A selected chart has no runs: Font Color colours its text (title, axes, legend, labels),
|
||||
// which used to answer and change nothing.
|
||||
let st = s.doc()?;
|
||||
if st.selection.text.is_none() {
|
||||
let charts: Vec<ShapeId> =
|
||||
st.selection.shapes.iter().copied().filter(|id| matches!(st.shape(*id).map(|sh| &sh.kind), Some(ShapeKind::Chart(_)))).collect();
|
||||
let only_charts = charts.len() == st.selection.shapes.len();
|
||||
if !charts.is_empty() {
|
||||
let tc = c.clone();
|
||||
edit_shapes(s, &json!({"ids": charts}), "format.color", |sh| {
|
||||
if let ShapeKind::Chart(ch) = &mut sh.kind {
|
||||
ch.text_color = Some(tc.clone());
|
||||
ch.raw = None;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
if only_charts {
|
||||
return ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
format_run(s, &move |r| r.fill = Some(Fill::solid(c.clone())))
|
||||
}
|
||||
/// `a:alpha` for `transparency` (0–1) on `c`, replacing any alpha it had; fully opaque drops it.
|
||||
@@ -692,7 +713,8 @@ fn case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() };
|
||||
super::text::edit_text(s, move |body, t| {
|
||||
for pi in a.0..=b.0 {
|
||||
let last = body.paragraphs.len().saturating_sub(1);
|
||||
for pi in a.0..=b.0.min(last) {
|
||||
let Some(para) = body.paragraphs.get_mut(pi) else { continue };
|
||||
let n = para.char_len();
|
||||
let from = if pi == a.0 { a.1 } else { 0 };
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! slide. Everything is computed here, offline.
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::{Fill, Presentation, Rgba, Shape, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -57,9 +58,44 @@ fn roles(shapes: &[Shape]) -> Roles {
|
||||
r
|
||||
}
|
||||
|
||||
/// The ideas for slide `index` (none when it has nothing to arrange).
|
||||
/// Whether `s` paints a fill or an outline once inherited and style-referenced values are
|
||||
/// resolved, as the renderer does (`fill: None` alone means inherited, not invisible).
|
||||
fn shows(ctx: Option<&Ctx>, s: &Shape) -> bool {
|
||||
let visible = |f: &Option<Fill>| f.as_ref().is_some_and(|f| !f.is_none());
|
||||
match ctx {
|
||||
Some(ctx) => visible(&resolve::fill(ctx, s).0) || visible(&resolve::line(ctx, s).0.fill),
|
||||
None => visible(&s.fill) || visible(&s.line.as_ref().and_then(|l| l.fill.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an idea may move this shape: a picture, a placeholder or a plain text box. Diagram
|
||||
/// boxes, connectors, groups, tables, charts and media are laid out by their own geometry, which
|
||||
/// the ideas do not know, so a slide holding one gets no ideas.
|
||||
fn arrangeable(ctx: Option<&Ctx>, s: &Shape) -> bool {
|
||||
match s.kind {
|
||||
ShapeKind::Picture { .. } => true,
|
||||
ShapeKind::Shape => {
|
||||
let is_title = s.ph.as_ref().is_some_and(|p| p.kind.is_title());
|
||||
let has_text = s.text.as_ref().is_some_and(|t| !t.text().trim().is_empty());
|
||||
// Only titles and shapes with text are moved (see `roles`): an empty one that shows
|
||||
// (filled or outlined) would stay where it is, over the rearranged slide.
|
||||
(s.ph.is_some() || s.text_box) && (is_title || has_text || !shows(ctx, s))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The ideas for slide `index` (none when it has nothing to arrange, or other content in the way).
|
||||
pub fn ideas_for(doc: &Presentation, index: usize) -> Vec<Idea> {
|
||||
let Some(slide) = doc.slides.get(index) else { return vec![] };
|
||||
let ctx = Ctx::for_slide(doc, slide);
|
||||
if slide.shapes.iter().any(|s| !s.hidden && !arrangeable(ctx.as_ref(), s)) {
|
||||
return vec![];
|
||||
}
|
||||
// The ideas move one picture; a second would stay put, over the rearranged text.
|
||||
if slide.shapes.iter().filter(|s| !s.hidden && matches!(s.kind, ShapeKind::Picture { .. })).count() > 1 {
|
||||
return vec![];
|
||||
}
|
||||
let r = roles(&slide.shapes);
|
||||
if r.picture.is_some() {
|
||||
PICTURE_IDEAS.to_vec()
|
||||
@@ -249,6 +285,7 @@ mod tests {
|
||||
};
|
||||
let body = Shape {
|
||||
id: ShapeId(501),
|
||||
text_box: true,
|
||||
xfrm: Some(Xfrm::new(50.0, 140.0, 860.0, 350.0)),
|
||||
text: Some(deckcraft_model::text::TextBody::from_text("one\ntwo")),
|
||||
..Default::default()
|
||||
@@ -414,6 +451,91 @@ mod tests {
|
||||
assert_eq!(shape(&s, 500).fill, Some(band_fill()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slides_with_diagrams_or_other_graphics_get_no_ideas() {
|
||||
// A filled autoshape with text (a diagram box) is not text to restack.
|
||||
let mut s = deck(false);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes.push(Shape {
|
||||
id: ShapeId(510),
|
||||
xfrm: Some(Xfrm::new(300.0, 200.0, 200.0, 80.0)),
|
||||
fill: Some(Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(200, 80, 20, 255)))),
|
||||
text: Some(deckcraft_model::text::TextBody::from_text("Solar wind")),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty());
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "titleColumn"})).is_err());
|
||||
// A connector too, and a hidden one does not count.
|
||||
let mut s = deck(false);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes.push(Shape {
|
||||
id: ShapeId(511),
|
||||
kind: ShapeKind::Connector { start: None, end: None },
|
||||
xfrm: Some(Xfrm::new(300.0, 200.0, 100.0, 0.0)),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty());
|
||||
Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]).shapes[2].hidden = true;
|
||||
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_visible_pictures_get_no_ideas_but_a_hidden_second_one_does_not_count() {
|
||||
let mut s = deck(true);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes.push(Shape {
|
||||
id: ShapeId(503),
|
||||
kind: ShapeKind::Picture { fill: PictureFill::default() },
|
||||
xfrm: Some(Xfrm::new(600.0, 300.0, 200.0, 100.0)),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty());
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).is_err());
|
||||
Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]).shapes[3].hidden = true;
|
||||
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_text_box_blocks_ideas_only_when_it_shows() {
|
||||
let mut s = deck(false);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes.push(Shape { id: ShapeId(512), text_box: true, xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 40.0)), ..Default::default() });
|
||||
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3, "invisible and empty: nothing in the way");
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes[2].fill = Some(Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(200, 80, 20, 255))));
|
||||
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty(), "a filled empty box would stay on top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_box_with_an_explicit_no_fill_does_not_block_ideas() {
|
||||
let mut s = deck(false);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes.push(Shape {
|
||||
id: ShapeId(512),
|
||||
text_box: true,
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 40.0)),
|
||||
fill: Some(Fill::None),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3, "an explicit no fill shows nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_box_filled_through_its_style_blocks_ideas() {
|
||||
let mut s = deck(false);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
let mut style = deckcraft_model::style::ShapeStyle::accent(deckcraft_model::SchemeSlot::Accent1);
|
||||
style.line_ref.0 = 0;
|
||||
slide.shapes.push(Shape {
|
||||
id: ShapeId(512),
|
||||
text_box: true,
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 40.0)),
|
||||
style: Some(style),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty(), "the theme fill shows, so the box would stay on top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_crop_matches_the_box_aspect() {
|
||||
let c = cover_crop(2.0, 1.0);
|
||||
|
||||
@@ -118,7 +118,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Chart Elements",
|
||||
["Chart Design", "Chart Layouts"],
|
||||
None,
|
||||
"{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, upDownBars?: bool, id?}",
|
||||
"{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, upDownBars?: bool, textColor?: #RRGGBB|accent1..6|tx1…|null (title, axes, legend and labels; null for PowerPoint's default: the theme's text colour at 65 % plus 35 %, 595959 on a light theme), id?}",
|
||||
has_selection,
|
||||
chart_options
|
||||
),
|
||||
@@ -1804,7 +1804,19 @@ fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Waterfall total points.
|
||||
let subtotals: Option<Vec<usize>> =
|
||||
p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect());
|
||||
// Colour of the chart's text (title, axes, legend, data labels); null goes back to PowerPoint's
|
||||
// default (tx1 at lumMod 65 % / lumOff 35 %).
|
||||
let text_color = match p.get("textColor") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(v) => {
|
||||
Some(Some(color_value(v).ok_or_else(|| bad("chart.options", "`textColor` is a colour: #RRGGBB, accent1…accent6, tx1…, or null"))?))
|
||||
}
|
||||
};
|
||||
with_chart(s, p, "chart.options", |c| {
|
||||
if let Some(t) = &text_color {
|
||||
c.text_color = t.clone();
|
||||
}
|
||||
apply_bin_options(c, bin_count, bin_size);
|
||||
apply_subtotals_and_bars(c, p, subtotals.as_deref());
|
||||
if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() {
|
||||
@@ -1818,6 +1830,10 @@ fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if let Some(v) = bool_param(p, "dataLabels") {
|
||||
c.data_labels = v;
|
||||
// A series' own setting would otherwise keep overriding the chart-wide switch.
|
||||
for series in &mut c.series {
|
||||
series.data_labels = None;
|
||||
}
|
||||
}
|
||||
if let Some(v) = bool_param(p, "gridlines") {
|
||||
c.gridlines = v;
|
||||
|
||||
+180
-12
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
edit_points
|
||||
),
|
||||
cmd!(query "shape.points", "Points", [], None, "{id?} → {id, paths: [[[x, y] slide pt]], types: [[smooth|straight|corner]]} vertices of a freeform", has_selection, points),
|
||||
cmd!(query "shape.points", "Points", [], None, "{id?} → {id, paths: [[[x, y] slide pt]], types: [[smooth|straight|corner]], segments: [[straight|curved|null]]} vertices of a freeform and the segment ending at each", has_selection, points),
|
||||
cmd!("shape.movePoint", "Move Point", [], None, "{path?: 0, index, x, y: slide pt, id?}", has_selection, move_point),
|
||||
cmd!(
|
||||
"shape.addPoint",
|
||||
@@ -54,6 +54,33 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
move_handle
|
||||
),
|
||||
cmd!("shape.deletePoint", "Delete Point", [], None, "{path?: 0, index, id?}", has_selection, delete_point),
|
||||
cmd!(
|
||||
"shape.segmentType",
|
||||
"Segment Type",
|
||||
[],
|
||||
None,
|
||||
"{path?: 0, segment: index of the vertex ending it, type: straight|curved, id?} — straight drops the segment's handles; curved gives it handles at its thirds",
|
||||
has_selection,
|
||||
segment_type
|
||||
),
|
||||
cmd!(
|
||||
"shape.openPath",
|
||||
"Open Path",
|
||||
[],
|
||||
None,
|
||||
"{path?: 0, index, id?} — break a closed outline at a point, which becomes its two ends",
|
||||
has_selection,
|
||||
open_path
|
||||
),
|
||||
cmd!(
|
||||
"shape.closePath",
|
||||
"Close Path",
|
||||
[],
|
||||
None,
|
||||
"{path?: 0, index: any point of the open outline, id?} — join its ends with a straight segment (one point when they meet)",
|
||||
has_selection,
|
||||
close_path
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -281,6 +308,76 @@ fn remove_vertex(segs: &mut Vec<Seg>, si: usize) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The sub-path holding segment `si`: from its move up to the next move (or the end).
|
||||
fn subpath(segs: &[Seg], si: usize) -> (usize, usize) {
|
||||
let from = segs.get(..=si).and_then(|s| s.iter().rposition(|x| matches!(x, Seg::M(_)))).unwrap_or(0);
|
||||
let to = (si + 1..segs.len()).find(|j| matches!(segs.get(*j), Some(Seg::M(_)))).unwrap_or(segs.len());
|
||||
(from, to)
|
||||
}
|
||||
|
||||
/// The segment that ends at the vertex ending segment `si`: the closing edge for a closed
|
||||
/// sub-path's start, none for an open one's.
|
||||
fn ending_segment(segs: &[Seg], si: usize) -> Option<usize> {
|
||||
if matches!(segs.get(si), Some(Seg::M(_))) {
|
||||
(si + 1..segs.len()).take_while(|j| !matches!(segs.get(*j), Some(Seg::M(_)))).find(|j| closes_onto_start(segs, *j))
|
||||
} else {
|
||||
Some(si)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether segment `j` is a straight line or a curve.
|
||||
fn segment_kind(segs: &[Seg], j: usize) -> Option<&'static str> {
|
||||
match segs.get(j)? {
|
||||
Seg::L(_) => Some("straight"),
|
||||
Seg::C(..) | Seg::Q(..) => Some("curved"),
|
||||
Seg::M(_) | Seg::Z => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes segment `j` a straight line (dropping its handles) or a curve with handles at its
|
||||
/// thirds, so it keeps its shape until a handle is dragged.
|
||||
fn set_segment(segs: &mut [Seg], j: usize, curved: bool) {
|
||||
if curved {
|
||||
to_cubic(segs, j);
|
||||
} else if let Some(s) = segs.get_mut(j)
|
||||
&& let Seg::C(_, _, p) | Seg::Q(_, p) = *s
|
||||
{
|
||||
*s = Seg::L(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the closed sub-path through the vertex ending segment `si` there: it now starts and ends
|
||||
/// on that vertex, as two points. Returns false for an open sub-path.
|
||||
fn open_at(segs: &mut Vec<Seg>, si: usize) -> bool {
|
||||
let (from, to) = subpath(segs, si);
|
||||
let Some(z) = to.checked_sub(1).filter(|z| *z > si && matches!(segs.get(*z), Some(Seg::Z))) else { return false };
|
||||
let Some(v) = segs.get(si).and_then(Seg::end) else { return false };
|
||||
let (Some(after), Some(before)) = (segs.get(si + 1..z), segs.get(from + 1..=si)) else { return false };
|
||||
let mut out = vec![Seg::M(v)];
|
||||
out.extend_from_slice(after);
|
||||
out.extend_from_slice(before);
|
||||
segs.splice(from..to, out);
|
||||
true
|
||||
}
|
||||
|
||||
/// Closes the open sub-path through the vertex ending segment `si`: a straight segment joins its
|
||||
/// ends, or they become one point when they meet. Returns false for a closed sub-path.
|
||||
fn close_at(segs: &mut Vec<Seg>, si: usize) -> bool {
|
||||
let (from, to) = subpath(segs, si);
|
||||
let last = to.saturating_sub(1);
|
||||
if last <= from || matches!(segs.get(last), Some(Seg::Z)) {
|
||||
return false;
|
||||
}
|
||||
let (Some(Seg::M(start)), Some(end)) = (segs.get(from).copied(), segs.get(last).and_then(Seg::end)) else { return false };
|
||||
let mut add = Vec::new();
|
||||
if (start - end).hypot() >= 1e-6 {
|
||||
add.push(Seg::L(start));
|
||||
}
|
||||
add.push(Seg::Z);
|
||||
segs.splice(to..to, add);
|
||||
true
|
||||
}
|
||||
|
||||
/// Custom paths of a preset shape, in shape-local points.
|
||||
fn to_custom(sh: &Shape, w: f64, h: f64) -> Vec<CustomPath> {
|
||||
let geo = deckcraft_render::shape_geometry(sh, w, h);
|
||||
@@ -402,6 +499,7 @@ pub(crate) fn prepare(s: &mut Session, id: ShapeId) -> Result<()> {
|
||||
fn edit_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let on = p.get("on").and_then(Value::as_bool).unwrap_or(s.tool.edit_points.is_none());
|
||||
s.tool.edit_vertex = None;
|
||||
s.tool.edit_segment = None;
|
||||
if !on {
|
||||
s.tool.edit_points = None;
|
||||
return Ok(json!({"editing": Value::Null}));
|
||||
@@ -436,17 +534,16 @@ fn points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sh = st.shape(id).ok_or_else(|| bad("shape.points", "no such shape"))?;
|
||||
let paths: Vec<Vec<[f64; 2]>> =
|
||||
slide_vertices(&st.doc, &st.selection, sh).into_iter().map(|v| v.into_iter().map(|q| [q.x, q.y]).collect()).collect();
|
||||
let types: Vec<Vec<&str>> = match &sh.geom {
|
||||
Geom::Custom { paths } => paths
|
||||
.iter()
|
||||
.map(|cp| {
|
||||
let segs = parse(&cp.d);
|
||||
vertices(&segs).iter().map(|si| point_kind(&segs, *si)).collect()
|
||||
})
|
||||
.collect(),
|
||||
Geom::Preset { .. } => vec![],
|
||||
};
|
||||
Ok(json!({"id": id.0, "paths": paths, "types": types}))
|
||||
let (mut types, mut kinds): (Vec<Vec<&str>>, Vec<Vec<Option<&str>>>) = (vec![], vec![]);
|
||||
if let Geom::Custom { paths } = &sh.geom {
|
||||
for cp in paths {
|
||||
let segs = parse(&cp.d);
|
||||
let vs = vertices(&segs);
|
||||
types.push(vs.iter().map(|si| point_kind(&segs, *si)).collect());
|
||||
kinds.push(vs.iter().map(|si| ending_segment(&segs, *si).and_then(|j| segment_kind(&segs, j))).collect());
|
||||
}
|
||||
}
|
||||
Ok(json!({"id": id.0, "paths": paths, "types": types, "segments": kinds}))
|
||||
}
|
||||
|
||||
/// Runs `f` on the segments of path `path` of `id` (in shape-local points), then refits the box.
|
||||
@@ -784,3 +881,74 @@ pub fn kind_of(sh: &Shape, path: usize, index: usize) -> Option<&'static str> {
|
||||
pub(crate) fn move_handle_raw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
move_handle(s, p)
|
||||
}
|
||||
|
||||
fn segment_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "shape.segmentType")?;
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let seg = index_param(p, "segment", "shape.segmentType")?;
|
||||
let curved = match str_param(p, "type") {
|
||||
Some("straight") => false,
|
||||
Some("curved") => true,
|
||||
_ => return Err(bad("shape.segmentType", "`type` is straight or curved")),
|
||||
};
|
||||
// Already that type: nothing to change, so no undo step.
|
||||
let want = if curved { "curved" } else { "straight" };
|
||||
if s.doc()?.shape(id).is_some_and(|sh| segment_kind_of(sh, path, seg) == Some(want)) {
|
||||
return ok();
|
||||
}
|
||||
edit_path(s, id, path, "shape.segmentType", move |segs, vs, _| {
|
||||
let si = *vs.get(seg).ok_or_else(|| bad("shape.segmentType", "no such segment"))?;
|
||||
let j = ending_segment(segs, si).ok_or_else(|| bad("shape.segmentType", "the first point of an open outline ends no segment"))?;
|
||||
set_segment(segs, j, curved);
|
||||
Ok(true)
|
||||
})
|
||||
}
|
||||
|
||||
fn open_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "shape.openPath")?;
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let index = index_param(p, "index", "shape.openPath")?;
|
||||
let r = edit_path(s, id, path, "shape.openPath", move |segs, vs, _| {
|
||||
let si = *vs.get(index).ok_or_else(|| bad("shape.openPath", "no such point"))?;
|
||||
if open_at(segs, si) { Ok(true) } else { Err(bad("shape.openPath", "the outline is open already")) }
|
||||
})?;
|
||||
forget_kinds(s, path);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn close_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "shape.closePath")?;
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let index = index_param(p, "index", "shape.closePath")?;
|
||||
let r = edit_path(s, id, path, "shape.closePath", move |segs, vs, _| {
|
||||
let si = *vs.get(index).ok_or_else(|| bad("shape.closePath", "no such point"))?;
|
||||
if close_at(segs, si) { Ok(true) } else { Err(bad("shape.closePath", "the outline is closed already")) }
|
||||
})?;
|
||||
forget_kinds(s, path);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Opening or closing renumbers the points of `path`: a point type remembered for one of them
|
||||
/// would follow the wrong point.
|
||||
fn forget_kinds(s: &mut Session, path: usize) {
|
||||
if s.tool.vertex_kind.as_ref().is_some_and(|k| k.0 == path) {
|
||||
s.tool.vertex_kind = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the segment ending at vertex `index` of path `path` of `sh` is straight or curved.
|
||||
pub fn segment_kind_of(sh: &Shape, path: usize, index: usize) -> Option<&'static str> {
|
||||
let Geom::Custom { paths } = &sh.geom else { return None };
|
||||
let segs = parse(&paths.get(path)?.d);
|
||||
let si = *vertices(&segs).get(index)?;
|
||||
segment_kind(&segs, ending_segment(&segs, si)?)
|
||||
}
|
||||
|
||||
/// Whether the outline through vertex `index` of path `path` of `sh` is closed.
|
||||
pub fn closed_at(sh: &Shape, path: usize, index: usize) -> Option<bool> {
|
||||
let Geom::Custom { paths } = &sh.geom else { return None };
|
||||
let segs = parse(&paths.get(path)?.d);
|
||||
let si = *vertices(&segs).get(index)?;
|
||||
let (_, to) = subpath(&segs, si);
|
||||
Some(matches!(segs.get(to.checked_sub(1)?), Some(Seg::Z)))
|
||||
}
|
||||
|
||||
@@ -10,22 +10,41 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
|
||||
use deckcraft_model::{LayoutId, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session, media_refs};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"slide.reuse",
|
||||
"Reuse Slides…",
|
||||
["Home", "Slides", "New Slide"],
|
||||
None,
|
||||
"{path? | data: base64, name?, slides?: [index] (0-based, default all), keepFormatting?: bool (default false: use the destination theme), at?: index (default after the current slide)}",
|
||||
has_doc,
|
||||
reuse
|
||||
)]
|
||||
vec![
|
||||
cmd!(
|
||||
query "slide.reuseList",
|
||||
"List Slides to Reuse",
|
||||
[],
|
||||
None,
|
||||
"{path | data: base64, name?} → {name, slides: [{index, title, hidden}]}: the slides of another presentation, for choosing which ones `slide.reuse` brings in",
|
||||
has_doc,
|
||||
reuse_list
|
||||
),
|
||||
cmd!(
|
||||
"slide.reuse",
|
||||
"Reuse Slides…",
|
||||
["Home", "Slides", "New Slide"],
|
||||
None,
|
||||
"{path? | data: base64, name?, slides?: [index] (0-based, default all), keepFormatting?: bool (default false: use the destination theme), at?: index (default after the current slide)}",
|
||||
has_doc,
|
||||
reuse
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn reuse_list(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (name, bytes) = super::insert::media_bytes(p, "slide.reuseList")?;
|
||||
let src = super::file::open_presentation(&name, &bytes)?;
|
||||
let slides: Vec<Value> =
|
||||
src.slides.iter().enumerate().map(|(index, sl)| json!({"index": index, "title": sl.title(), "hidden": sl.hidden})).collect();
|
||||
Ok(json!({"name": name, "slides": slides}))
|
||||
}
|
||||
|
||||
/// Point every media id under `x` at its copy in the destination (`map`: source → destination).
|
||||
@@ -114,9 +133,9 @@ fn relink_shapes(shapes: &mut [deckcraft_model::Shape], map: &HashMap<SlideId, S
|
||||
}
|
||||
|
||||
/// Fresh shape ids for `shapes`, and the old → new map.
|
||||
fn renumber(doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
|
||||
fn renumber(cmd: &str, doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
|
||||
let old = shape_ids(shapes);
|
||||
super::edit::reid("slide.reuse", doc, shapes)?;
|
||||
super::edit::reid(cmd, doc, shapes)?;
|
||||
let map: HashMap<ShapeId, ShapeId> = old.into_iter().zip(shape_ids(shapes)).collect();
|
||||
remap_connectors(shapes, &map, 0);
|
||||
Ok(map)
|
||||
@@ -171,19 +190,7 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
masters_done.push(m.id);
|
||||
let mut used = vec![];
|
||||
media_refs::visit::master(m, &mut |id: &MediaId| used.push(*id));
|
||||
copy_media(doc, &src, &used, &mut media_map)?;
|
||||
let mut m = m.clone();
|
||||
media_refs::visit_mut::master(&mut m, &mut remap(&media_map));
|
||||
m.id = MasterId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
|
||||
renumber(doc, &mut m.shapes)?;
|
||||
for l in &mut m.layouts {
|
||||
let new = LayoutId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
|
||||
layout_map.insert(l.id, new);
|
||||
l.id = new;
|
||||
renumber(doc, &mut l.shapes)?;
|
||||
}
|
||||
let m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
|
||||
doc.masters.push(Arc::new(m));
|
||||
}
|
||||
}
|
||||
@@ -192,11 +199,11 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut slide_map: HashMap<SlideId, SlideId> = HashMap::new();
|
||||
for &i in &picked {
|
||||
let Some(from) = src.slides.get(i) else { continue };
|
||||
copy_media(doc, &src, &media_refs::slide_media(from), &mut media_map)?;
|
||||
copy_media("slide.reuse", doc, &src, &media_refs::slide_media(from), &mut media_map)?;
|
||||
let mut sl: Slide = from.as_ref().clone();
|
||||
media_refs::visit_mut::slide(&mut sl, &mut remap(&media_map));
|
||||
sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
|
||||
let map = renumber(doc, &mut sl.shapes)?;
|
||||
let map = renumber("slide.reuse", doc, &mut sl.shapes)?;
|
||||
for a in &mut sl.animations {
|
||||
a.shape = map.get(&a.shape).copied().unwrap_or(a.shape);
|
||||
a.trigger = a.trigger.map(|t| map.get(&t).copied().unwrap_or(t));
|
||||
@@ -227,9 +234,35 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A copy of `src`'s master `m` ready to join `doc`: its media copied in, fresh master, layout and
|
||||
/// shape ids. `layouts` gains each source layout id → its copy's.
|
||||
pub(crate) fn import_master(
|
||||
cmd: &str,
|
||||
doc: &mut Presentation,
|
||||
src: &Presentation,
|
||||
m: &Master,
|
||||
media_map: &mut HashMap<MediaId, MediaId>,
|
||||
layouts: &mut HashMap<LayoutId, LayoutId>,
|
||||
) -> Result<Master> {
|
||||
let mut used = vec![];
|
||||
media_refs::visit::master(m, &mut |id: &MediaId| used.push(*id));
|
||||
copy_media(cmd, doc, src, &used, media_map)?;
|
||||
let mut m = m.clone();
|
||||
media_refs::visit_mut::master(&mut m, &mut remap(media_map));
|
||||
m.id = MasterId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
|
||||
renumber(cmd, doc, &mut m.shapes)?;
|
||||
for l in &mut m.layouts {
|
||||
let new = LayoutId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
|
||||
layouts.insert(l.id, new);
|
||||
l.id = new;
|
||||
renumber(cmd, doc, &mut l.shapes)?;
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Copy the source media `used` into `doc` under fresh ids, once each (identical bytes already in
|
||||
/// `doc` are shared; a linked item keeps its link).
|
||||
fn copy_media(doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: &mut HashMap<MediaId, MediaId>) -> Result<()> {
|
||||
fn copy_media(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: &mut HashMap<MediaId, MediaId>) -> Result<()> {
|
||||
for &id in used {
|
||||
if map.contains_key(&id) {
|
||||
continue;
|
||||
@@ -237,12 +270,12 @@ fn copy_media(doc: &mut Presentation, src: &Presentation, used: &[MediaId], map:
|
||||
let Some(m) = src.media(id) else { continue };
|
||||
let new = if m.link.is_some() {
|
||||
let mut item = m.clone();
|
||||
item.id = MediaId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
|
||||
item.id = MediaId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
|
||||
let new = item.id;
|
||||
doc.media.push(item);
|
||||
new
|
||||
} else {
|
||||
doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?
|
||||
doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad(cmd, NO_ID_SPACE))?
|
||||
};
|
||||
map.insert(id, new);
|
||||
}
|
||||
@@ -397,4 +430,34 @@ mod tests {
|
||||
assert_eq!(click(first.shapes.get(2)), Some(Action::Slide { slide: left_behind }), "a slide not reused keeps its link");
|
||||
assert_eq!(click(copy(1).shapes.first()), to(0), "a link back to an earlier copy follows too");
|
||||
}
|
||||
|
||||
/// The list that lets a dialog offer the slides of the other file; picking from it brings in
|
||||
/// exactly those.
|
||||
#[test]
|
||||
fn reuse_list_names_the_slides_and_the_picked_ones_come_in() {
|
||||
let mut src = Session::with_new();
|
||||
for t in ["Alpha", "Beta", "Gamma"] {
|
||||
src.execute("slide.new", &json!({"layout": "titleAndContent", "title": t})).expect("new");
|
||||
}
|
||||
let last_slide = src.doc().expect("doc").doc.slides.len() - 1;
|
||||
src.execute("slide.hide", &json!({"index": last_slide})).expect("hide");
|
||||
let bytes = crate::cmd::file::save_bytes(&src.doc().expect("doc").doc, "deckcraft").expect("save");
|
||||
let data = super::super::base64_encode(&bytes);
|
||||
|
||||
let mut s = Session::with_new();
|
||||
let listed = s.execute("slide.reuseList", &json!({"data": data, "name": "src.deckcraft"})).expect("list");
|
||||
let slides = listed["slides"].as_array().expect("slides");
|
||||
let title = |i: usize| slides.iter().find(|x| x["index"] == i).and_then(|x| x["title"].as_str()).unwrap_or_default().to_string();
|
||||
assert!(slides.len() >= 3);
|
||||
let (a, b, g) = (title(slides.len() - 3), title(slides.len() - 2), title(slides.len() - 1));
|
||||
assert_eq!((a.as_str(), b.as_str(), g.as_str()), ("Alpha", "Beta", "Gamma"));
|
||||
assert_eq!(slides.last().map(|x| x["hidden"].clone()), Some(json!(true)));
|
||||
assert_eq!(s.doc().expect("doc").doc.slides.len(), 1, "listing changes nothing");
|
||||
|
||||
let last = slides.len() - 1;
|
||||
s.execute("slide.reuse", &json!({"data": data, "name": "src.deckcraft", "slides": [last - 2, last]})).expect("reuse");
|
||||
let titles: Vec<String> = s.doc().expect("doc").doc.slides.iter().map(|x| x.title()).collect();
|
||||
assert!(titles.iter().any(|t| t == "Alpha") && titles.iter().any(|t| t == "Gamma"));
|
||||
assert!(!titles.iter().any(|t| t == "Beta"), "a slide that was not picked stays out");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare with a revised copy → {name, changes}", has_doc, compare),
|
||||
cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide, revised, details}]}", has_doc, revisions),
|
||||
cmd!("review.accept", "Accept Revision", ["Review", "Compare"], None, "{index} | {all: true}", comparing, accept),
|
||||
cmd!(
|
||||
noundo "review.reject",
|
||||
"Reject Revision",
|
||||
["Review", "Compare"],
|
||||
None,
|
||||
"{index} | {all: true} → {rejected} (drop revisions without applying them: the open deck stays as it is; not an undo step)",
|
||||
comparing,
|
||||
reject
|
||||
),
|
||||
cmd!(noundo "review.endCompare", "End Review", ["Review", "Compare"], None, "{}", comparing, end_compare),
|
||||
cmd!(noundo "show.fromStart", "Play from Start", ["Slide Show", "Start Slide Show"], Some("Cmd+Shift+Enter"), "{}", has_slide, |s, _| start(s, 0)),
|
||||
cmd!(noundo "show.fromCurrent", "Play from Current Slide", ["Slide Show", "Start Slide Show"], Some("Cmd+Enter"), "{}", has_slide, |s, _| {
|
||||
@@ -655,7 +664,7 @@ fn revised_bytes(p: &Value) -> Result<(String, Vec<u8>)> {
|
||||
fn revisions_json(st: &crate::DocState) -> Result<Value> {
|
||||
let c = st.compare.as_ref().ok_or_else(|| bad("review.revisions", "not comparing"))?;
|
||||
let name = std::path::Path::new(&c.name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| c.name.clone());
|
||||
Ok(json!({"name": name, "changes": crate::compare::diff(&st.doc, &c.revised, c.by_position), "byPosition": c.by_position}))
|
||||
Ok(json!({"name": name, "changes": crate::compare::pending(&st.doc, c), "byPosition": c.by_position}))
|
||||
}
|
||||
|
||||
fn compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -663,7 +672,7 @@ fn compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let revised = super::file::open_presentation(&name, &bytes)?;
|
||||
let st = s.doc_mut()?;
|
||||
let by_position = crate::compare::unrelated(&st.doc, &revised);
|
||||
st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position });
|
||||
st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position, rejected: Default::default() });
|
||||
s.ui_requests.push(UiRequest::Pane { id: "revisions".into() });
|
||||
revisions_json(s.doc()?)
|
||||
}
|
||||
@@ -673,7 +682,8 @@ fn revisions(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn accept(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (rev, by_pos) = s.doc()?.compare.as_ref().map(|c| (c.revised.clone(), c.by_position)).ok_or_else(|| bad("review.accept", "not comparing"))?;
|
||||
let cmp = s.doc()?.compare.clone().ok_or_else(|| bad("review.accept", "not comparing"))?;
|
||||
let rev = cmp.revised.clone();
|
||||
let all = bool_param(p, "all").unwrap_or(false);
|
||||
let index = usize_param(p, "index");
|
||||
if !all && index.is_none() {
|
||||
@@ -684,7 +694,7 @@ fn accept(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Each accept shifts indices, so the list is recomputed; bounded by the slide counts.
|
||||
let limit = doc.slides.len().saturating_add(rev.slides.len()).saturating_add(1);
|
||||
for _ in 0..limit {
|
||||
let changes = crate::compare::diff(doc, &rev, by_pos);
|
||||
let changes = crate::compare::pending(doc, &cmp);
|
||||
let c = if all {
|
||||
changes.into_iter().find(|c| !(c.kind == crate::compare::ChangeKind::Deleted && doc.slides.len() <= 1))
|
||||
} else {
|
||||
@@ -707,6 +717,24 @@ fn accept(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
fn reject(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let all = bool_param(p, "all").unwrap_or(false);
|
||||
let index = usize_param(p, "index");
|
||||
if !all && index.is_none() {
|
||||
return Err(bad("review.reject", "give `index` or `all`"));
|
||||
}
|
||||
let st = s.doc_mut()?;
|
||||
let doc = st.doc.clone();
|
||||
let c = st.compare.as_mut().ok_or_else(|| bad("review.reject", "not comparing"))?;
|
||||
let pending = crate::compare::pending(&doc, c);
|
||||
let picked: Vec<_> = if all { pending } else { pending.into_iter().nth(index.unwrap_or(0)).into_iter().collect() };
|
||||
if picked.is_empty() {
|
||||
return Err(bad("review.reject", "no such revision"));
|
||||
}
|
||||
c.rejected.extend(picked.iter().map(|ch| crate::compare::ChangeKey::of(&doc, ch)));
|
||||
Ok(json!({"rejected": picked.len()}))
|
||||
}
|
||||
|
||||
fn end_compare(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.doc_mut()?.compare = None;
|
||||
ok()
|
||||
|
||||
@@ -337,7 +337,12 @@ fn decode_text(raw: &str) -> String {
|
||||
|
||||
fn numeric_char(digits: &str) -> Option<char> {
|
||||
let hex = digits.strip_prefix('x').or_else(|| digits.strip_prefix('X'));
|
||||
let n = if let Some(hex) = hex { u32::from_str_radix(hex, 16).ok()? } else { digits.parse().ok()? };
|
||||
// `parse` and `from_str_radix` accept a leading '+', which is not a valid reference.
|
||||
let (body, radix) = hex.map_or((digits, 10), |h| (h, 16));
|
||||
if body.is_empty() || !body.chars().all(|c| c.is_digit(radix)) {
|
||||
return None;
|
||||
}
|
||||
let n = u32::from_str_radix(body, radix).ok()?;
|
||||
char::from_u32(n).filter(|c| !c.is_control() || *c == '\n')
|
||||
}
|
||||
|
||||
@@ -356,6 +361,13 @@ fn fold(s: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn signed_numeric_entities_are_kept_as_typed() {
|
||||
assert_eq!(decode_text("a&#+65;b"), "a&#+65;b");
|
||||
assert_eq!(decode_text("a&#x+41;b"), "a&#x+41;b");
|
||||
assert_eq!(decode_text("aAbA"), "aAbA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn google_docs_normal_weight_wrapper_is_not_bold() {
|
||||
let html = "<meta charset=\"utf-8\"><b style=\"font-weight:normal;\" id=\"docs-internal-guid-1\"><span style=\"font-weight:400\">plain words</span></b>";
|
||||
|
||||
@@ -373,7 +373,7 @@ fn select_slides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn hide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets: Vec<usize> = match usize_param(p, "index") {
|
||||
let targets: Vec<usize> = match opt_usize_param(p, "index", "slide.hide")? {
|
||||
Some(i) => vec![i],
|
||||
None => {
|
||||
let st = s.doc()?;
|
||||
@@ -442,7 +442,7 @@ pub fn apply_layout(doc: &mut Presentation, index: usize, layout: LayoutId) -> R
|
||||
|
||||
fn set_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let key = p.get("layout").cloned().ok_or_else(|| bad("slide.layout", "missing `layout`"))?;
|
||||
let targets: Vec<usize> = match usize_param(p, "index") {
|
||||
let targets: Vec<usize> = match opt_usize_param(p, "index", "slide.layout")? {
|
||||
Some(i) => vec![i],
|
||||
None => {
|
||||
let st = s.doc()?;
|
||||
|
||||
@@ -417,7 +417,8 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option<RunProps>)
|
||||
let c = ed::insert(body, at, &text, props);
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
for pi in at.0..=c.0 {
|
||||
let last = body.paragraphs.len().saturating_sub(1);
|
||||
for pi in at.0..=c.0.min(last) {
|
||||
if let Some(para) = body.paragraphs.get_mut(pi) {
|
||||
promote_rtl_if_needed(para, &inherited);
|
||||
}
|
||||
@@ -784,7 +785,8 @@ pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result<Value> {
|
||||
let c = ed::insert_body(body, at, src);
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
for pi in at.0..=c.0 {
|
||||
let last = body.paragraphs.len().saturating_sub(1);
|
||||
for pi in at.0..=c.0.min(last) {
|
||||
if let Some(para) = body.paragraphs.get_mut(pi) {
|
||||
promote_rtl_if_needed(para, &inherited);
|
||||
}
|
||||
@@ -1124,7 +1126,7 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => match to.as_str() {
|
||||
"up" => (caret.0.saturating_sub(1), caret.1),
|
||||
"down" => ((caret.0 + 1).min(body.paragraphs.len().saturating_sub(1)), caret.1),
|
||||
"down" => (caret.0.saturating_add(1).min(body.paragraphs.len().saturating_sub(1)), caret.1),
|
||||
"lineStart" => (caret.0, 0),
|
||||
_ => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)),
|
||||
},
|
||||
@@ -1189,9 +1191,11 @@ fn select_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = st.selection.text.clone().ok_or_else(|| bad("text.selectParagraph", "not editing text"))?;
|
||||
let at = pos_param(p, "at").unwrap_or(t.caret);
|
||||
let body = body_of(st, &t);
|
||||
// Clamp to the last paragraph so a huge index can neither overflow nor select past the body.
|
||||
let at = (at.0.min(body.map_or(0, |b| b.paragraphs.len().saturating_sub(1))), at.1);
|
||||
let n = body.and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
|
||||
// The paragraph mark is part of the paragraph (PowerPoint's triple-click selects it too).
|
||||
let end = if body.is_some_and(|b| at.0 + 1 < b.paragraphs.len()) { (at.0 + 1, 0) } else { (at.0, n) };
|
||||
let end = if body.is_some_and(|b| at.0.saturating_add(1) < b.paragraphs.len()) { (at.0.saturating_add(1), 0) } else { (at.0, n) };
|
||||
s.select(|_, sel| {
|
||||
if let Some(t) = sel.text.as_mut() {
|
||||
t.anchor = (at.0, 0);
|
||||
@@ -1371,7 +1375,8 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
if body.paragraphs.is_empty() {
|
||||
body.paragraphs.push(Default::default());
|
||||
}
|
||||
for pi in a.0..=b.0 {
|
||||
let last = body.paragraphs.len().saturating_sub(1);
|
||||
for pi in a.0..=b.0.min(last) {
|
||||
if let Some(p) = body.paragraphs.get_mut(pi) {
|
||||
f(p);
|
||||
}
|
||||
@@ -1519,3 +1524,23 @@ mod replace_font_tests {
|
||||
assert!(s.execute("font.replace", &json!({"from": "Inter"})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod select_paragraph_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn select_paragraph_huge_at() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap();
|
||||
s.execute("insert.textBox", &json!({"rect": [10, 10, 200, 50], "text": "hello"})).unwrap();
|
||||
s.execute("text.edit", &json!({})).unwrap();
|
||||
let r = s.execute("text.selectParagraph", &json!({"at": [u64::MAX, 0]}));
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
// Clamped to the last (only) paragraph, never past the end of the body.
|
||||
assert_eq!((t.anchor, t.caret), ((0, 0), (0, 5)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Review › Compare: the differences between the open presentation and a revised copy of it, slide
|
||||
//! by slide, and accepting a revised slide into the open one.
|
||||
//! by slide, and accepting a revised slide into the open one or rejecting it.
|
||||
//!
|
||||
//! Slides match by id (a revised copy of the same deck keeps them); when the two decks share no
|
||||
//! slide id at all, by position. Shapes match by id, then by name.
|
||||
@@ -18,6 +18,54 @@ pub struct Compare {
|
||||
/// Slides match by position (the decks shared no slide id when the compare started); fixed
|
||||
/// for the session so accepting slides doesn't switch the matching half way.
|
||||
pub by_position: bool,
|
||||
/// Revisions rejected: no longer listed and never accepted, the open deck left as it is.
|
||||
pub rejected: HashSet<ChangeKey>,
|
||||
}
|
||||
|
||||
/// What a revision is about, stable while slides move: the open slide (for changed and deleted)
|
||||
/// and the revised slide (for changed and inserted).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct ChangeKey {
|
||||
kind: ChangeKind,
|
||||
open: Option<SlideId>,
|
||||
revised: Option<usize>,
|
||||
}
|
||||
|
||||
impl ChangeKey {
|
||||
pub fn of(doc: &Presentation, change: &Change) -> ChangeKey {
|
||||
let open = match change.kind {
|
||||
ChangeKind::Inserted => None,
|
||||
ChangeKind::Changed | ChangeKind::Deleted => doc.slides.get(change.slide).map(|s| s.id),
|
||||
};
|
||||
ChangeKey { kind: change.kind, open, revised: change.revised }
|
||||
}
|
||||
}
|
||||
|
||||
/// The revisions still to review: [`diff`] without the rejected ones.
|
||||
pub fn pending(doc: &Presentation, c: &Compare) -> Vec<Change> {
|
||||
let mut out = diff(doc, &c.revised, c.by_position);
|
||||
out.retain(|ch| !is_rejected(doc, c, ch));
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `ch` was rejected. Positional matching re-pairs slides by index when the open deck gains
|
||||
/// or loses a slide, which changes the open half of the key; the revised index stays put, so any
|
||||
/// rejected key for the same revised slide counts. An open slide pushed past the end of the revised
|
||||
/// deck turns into a deletion; it stays kept when a rejected key names it.
|
||||
fn is_rejected(doc: &Presentation, c: &Compare, ch: &Change) -> bool {
|
||||
if c.rejected.contains(&ChangeKey::of(doc, ch)) {
|
||||
return true;
|
||||
}
|
||||
if !c.by_position {
|
||||
return false;
|
||||
}
|
||||
match ch.kind {
|
||||
ChangeKind::Deleted => {
|
||||
let open = doc.slides.get(ch.slide).map(|s| s.id);
|
||||
open.is_some() && c.rejected.iter().any(|k| k.open == open)
|
||||
}
|
||||
_ => ch.revised.is_some() && c.rejected.iter().any(|k| k.revised == ch.revised),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `cur` and `rev` share no slide id (then slides match by position).
|
||||
@@ -26,7 +74,7 @@ pub fn unrelated(cur: &Presentation, rev: &Presentation) -> bool {
|
||||
!cur.slides.iter().any(|s| ids.contains(&s.id))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ChangeKind {
|
||||
/// Only in the revised deck.
|
||||
@@ -434,6 +482,50 @@ mod tests {
|
||||
assert!(s.execute("review.accept", &json!({"all": true})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_keeps_the_open_deck_and_accept_all_skips_rejected_revisions() {
|
||||
let (mut s, rev, n) = decks();
|
||||
s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap();
|
||||
let before = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().clone();
|
||||
let ins = before.iter().position(|c| c["kind"] == "inserted").unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(s.execute("review.reject", &json!({"index": ins})).unwrap()["rejected"], 1);
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc().unwrap().doc), "rejecting leaves the open deck as it is");
|
||||
let left = s.execute("review.revisions", &json!({})).unwrap();
|
||||
assert_eq!(kinds(&left).len(), before.len() - 1);
|
||||
assert!(!kinds(&left).contains(&"inserted".into()), "{left}");
|
||||
// Accept All takes the rest, never the rejected slide.
|
||||
s.execute("review.accept", &json!({"all": true})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc.slides.len(), n - 1, "the deleted slide goes, the rejected insertion never comes");
|
||||
assert!(!st.doc.slides.iter().any(|x| super::slide_title(x) == "Brand new"));
|
||||
assert!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc().unwrap().doc));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "rejecting is not an undo step");
|
||||
// Reject All: nothing left to review, the deck as it was; Accept All then has nothing to take.
|
||||
assert_eq!(s.execute("review.reject", &json!({"all": true})).unwrap()["rejected"], before.len() - 1);
|
||||
assert!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().is_empty());
|
||||
assert!(s.execute("review.accept", &json!({"all": true})).is_err());
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc().unwrap().doc));
|
||||
assert!(s.execute("review.reject", &json!({"all": true})).is_err(), "nothing left to reject");
|
||||
assert!(s.execute("review.reject", &json!({})).is_err());
|
||||
s.execute("review.endCompare", &json!({})).unwrap();
|
||||
assert!(s.execute("review.reject", &json!({"index": 0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejecting_one_revision_keeps_the_others_in_order() {
|
||||
let (mut s, rev, _) = decks();
|
||||
s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap();
|
||||
let before = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().clone();
|
||||
assert!(s.execute("review.reject", &json!({"index": before.len()})).is_err());
|
||||
s.execute("review.reject", &json!({"index": 0})).unwrap();
|
||||
let left = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().clone();
|
||||
assert_eq!(left.as_slice(), &before[1..], "the rejected one goes, the others stay in order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_decks_match_by_position_and_bad_input_fails() {
|
||||
let mut s = Session::with_new();
|
||||
@@ -452,4 +544,65 @@ mod tests {
|
||||
assert!(s.execute("review.compare", &json!({})).is_err());
|
||||
assert!(s.execute("review.accept", &json!({"index": 999})).is_err());
|
||||
}
|
||||
|
||||
fn slide_texts(s: &Session) -> Vec<String> {
|
||||
s.doc().unwrap().doc.slides.iter().map(|sl| sl.shapes.iter().filter_map(|x| x.text.as_ref()).map(|t| t.text()).collect::<String>()).collect()
|
||||
}
|
||||
|
||||
/// By position, a rejected revision stays rejected when a slide is inserted into the open deck
|
||||
/// and the same revised slide now pairs with a different open slide.
|
||||
#[test]
|
||||
fn a_rejected_positional_revision_stays_rejected_after_a_slide_is_inserted() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "title", "title": "A"})).unwrap();
|
||||
let mut other = Session::with_new();
|
||||
other.execute("slide.new", &json!({"layout": "title", "title": "B"})).unwrap();
|
||||
let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
let v = s.execute("review.compare", &json!({"name": "o.pptx", "data": data})).unwrap();
|
||||
assert_eq!(v["byPosition"], true, "{v}");
|
||||
let changed = |v: &Value| -> Vec<u64> {
|
||||
v["changes"].as_array().unwrap().iter().filter(|c| c["kind"] == "changed").map(|c| c["revised"].as_u64().unwrap()).collect()
|
||||
};
|
||||
let revised = changed(&v);
|
||||
let last = *revised.last().expect("a changed slide");
|
||||
let at = v["changes"].as_array().unwrap().iter().position(|c| c["revised"] == last).unwrap();
|
||||
s.execute("review.reject", &json!({"index": at})).unwrap();
|
||||
// A new slide at the front shifts every open slide down by one.
|
||||
s.execute("slide.new", &json!({"layout": "title", "title": "N"})).unwrap();
|
||||
let n_at = slide_texts(&s).iter().position(|t| t.contains('N')).unwrap();
|
||||
s.execute("slide.move", &json!({"from": n_at, "to": 0})).unwrap();
|
||||
assert_eq!(slide_texts(&s).iter().position(|t| t.contains('N')), Some(0));
|
||||
let now = s.execute("review.revisions", &json!({})).unwrap();
|
||||
assert!(!changed(&now).contains(&last), "{now}");
|
||||
s.execute("review.accept", &json!({"all": true})).unwrap();
|
||||
let texts = slide_texts(&s);
|
||||
assert!(!texts.iter().any(|t| t.contains('B')), "{texts:?}");
|
||||
}
|
||||
|
||||
/// By position, inserting a slide before a rejected pair pushes the pair's open slide past the
|
||||
/// end of the revised deck; it must not turn into a deletion that Accept All carries out.
|
||||
#[test]
|
||||
fn a_rejected_positional_revision_does_not_become_a_deletion_when_a_slide_is_inserted() {
|
||||
let mut s = Session::with_new();
|
||||
let mut other = Session::with_new();
|
||||
for t in ["A", "X"] {
|
||||
s.execute("slide.new", &json!({"layout": "title", "title": t})).unwrap();
|
||||
}
|
||||
for t in ["B", "Y"] {
|
||||
other.execute("slide.new", &json!({"layout": "title", "title": t})).unwrap();
|
||||
}
|
||||
let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
let v = s.execute("review.compare", &json!({"name": "o.pptx", "data": data})).unwrap();
|
||||
assert_eq!(v["byPosition"], true, "{v}");
|
||||
let changes = v["changes"].as_array().unwrap();
|
||||
let last = changes.iter().filter(|c| c["kind"] == "changed").map(|c| c["revised"].as_u64().unwrap()).max().expect("a changed slide");
|
||||
let at = changes.iter().position(|c| c["kind"] == "changed" && c["revised"] == last).unwrap();
|
||||
let open_id = s.doc().unwrap().doc.slides[last as usize].id;
|
||||
s.execute("review.reject", &json!({"index": at})).unwrap();
|
||||
s.execute("slide.new", &json!({"layout": "title", "title": "N"})).unwrap();
|
||||
let n_at = slide_texts(&s).iter().position(|t| t.contains('N')).unwrap();
|
||||
s.execute("slide.move", &json!({"from": n_at, "to": 0})).unwrap();
|
||||
s.execute("review.accept", &json!({"all": true})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.slides.iter().any(|sl| sl.id == open_id), "the open slide whose revision was rejected was deleted");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ impl Session {
|
||||
if self.tool.edit_points.is_some() && matches!(k, "enter" | "return" | "escape") {
|
||||
self.tool.edit_points = None;
|
||||
self.tool.edit_vertex = None;
|
||||
self.tool.edit_segment = None;
|
||||
return Ok(json!({"handled": true, "command": "shape.editPoints"}));
|
||||
}
|
||||
if editing && let Some((id, params)) = self.text_key(k, mods) {
|
||||
|
||||
@@ -322,6 +322,36 @@ fn group_ungroup_roundtrip_positions() {
|
||||
assert!((v["box"]["x"].as_f64().unwrap() - 310.0).abs() < 1e-6, "{v}");
|
||||
}
|
||||
|
||||
/// Grouping a group again and again stops at `MAX_GROUP_DEPTH`: past about 39 levels a saved
|
||||
/// .deckcraft file no longer opens, and past 64 commands such as format.languageAll miss the text.
|
||||
#[test]
|
||||
fn groups_nest_only_as_deep_as_a_saved_file_reopens() {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
// A table (deeper in the file than a text box) at the bottom of the nesting.
|
||||
let mut g = s.execute("insert.table", &json!({"rows": 2, "cols": 2})).unwrap()["id"].as_u64().unwrap();
|
||||
for _ in 0..cmd::arrange::MAX_GROUP_DEPTH {
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 20, 20], "text": "deep"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("edit.select", &json!({"ids": [g, r]})).unwrap();
|
||||
g = s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
}
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 20, 20]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("edit.select", &json!({"ids": [g, r]})).unwrap();
|
||||
let before = s.doc().unwrap().revision;
|
||||
let err = s.execute("arrange.group", &json!({})).unwrap_err().to_string();
|
||||
assert!(err.contains(&format!("nested at most {}", cmd::arrange::MAX_GROUP_DEPTH)), "{err}");
|
||||
assert_eq!(s.doc().unwrap().revision, before, "a refused group leaves the slide as it was");
|
||||
// The deepest deck the command allows saves and opens again.
|
||||
let bytes = deckcraft_format::save(&s.doc().unwrap().doc).unwrap();
|
||||
let back = deckcraft_format::load(&bytes).unwrap();
|
||||
let depth = |shapes: &[deckcraft_model::Shape]| {
|
||||
let mut d = 0;
|
||||
deckcraft_model::walk(shapes, &mut |sh, depth| d = d.max(depth + usize::from(sh.is_group())));
|
||||
d
|
||||
};
|
||||
assert_eq!(depth(&back.slides.last().unwrap().shapes), cmd::arrange::MAX_GROUP_DEPTH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_and_distribute() {
|
||||
let mut s = session();
|
||||
@@ -338,6 +368,19 @@ fn align_and_distribute() {
|
||||
assert!((x1 - 255.0).abs() < 1e-6, "{x1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distribute_wide_middle_stays_in_selection_bounds() {
|
||||
let mut s = session();
|
||||
let mut ids = vec![];
|
||||
for (x, w) in [(0.0, 10.0), (20.0, 100.0), (30.0, 10.0)] {
|
||||
ids.push(s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10, w, 10]})).unwrap()["id"].as_u64().unwrap());
|
||||
}
|
||||
s.execute("edit.select", &json!({"ids": ids})).unwrap();
|
||||
s.execute("arrange.distribute", &json!({"dir": "horizontal", "to": "selection"})).unwrap();
|
||||
let xs: Vec<f64> = ids.iter().map(|i| s.execute("shape.inspect", &json!({"id": i})).unwrap()["box"]["x"].as_f64().unwrap()).collect();
|
||||
assert!(xs.iter().all(|x| (*x - [0.0, 10.0, 110.0][xs.iter().position(|y| y == x).unwrap()]).abs() < 1e-6), "{xs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_draw_move_resize() {
|
||||
let mut s = session();
|
||||
@@ -1537,6 +1580,24 @@ fn slide_ranges_parse_like_powerpoints_print_box() {
|
||||
assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_rtf_exports_and_opens_back() {
|
||||
let mut s = session();
|
||||
s.execute("slide.fromOutline", &json!({"text": "Roadmap\n\tShip 1.0\n\t\tLinux first\n"})).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-rtf-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("deck.rtf");
|
||||
s.execute("file.export", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert!(bytes.starts_with(b"{\\rtf1"));
|
||||
let text = crate::cmd::file::outline_text(&bytes);
|
||||
assert!(text.contains("Roadmap\n\tShip 1.0\n\t\tLinux first\n"), "{text}");
|
||||
s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.doc.slides.iter().any(|sl| sl.title() == "Roadmap"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #725: a layout with a small body placeholder ahead of its content placeholder (as Keynote
|
||||
/// exports "Title & Bullets") still gets the body in the content placeholder (`idx="1"`).
|
||||
#[test]
|
||||
@@ -2128,6 +2189,126 @@ fn edit_points_types_and_handles() {
|
||||
assert!((ho3.unwrap() - ho).hypot() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_points_straight_and_curved_segments() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap();
|
||||
s.execute("shape.editPoints", &json!({"on": true})).unwrap();
|
||||
let q = |s: &mut Session| s.execute("shape.points", &json!({})).unwrap();
|
||||
let before = q(&mut s)["paths"].clone();
|
||||
// The closing edge ends on the first point, so it is segment 0; an outline starts straight.
|
||||
assert_eq!(q(&mut s)["segments"][0], json!(["straight", "straight", "straight", "straight"]));
|
||||
// Curved Segment: the top edge gets handles at its thirds, so it looks the same until dragged.
|
||||
s.execute("shape.segmentType", &json!({"segment": 1, "type": "curved"})).unwrap();
|
||||
assert_eq!(q(&mut s)["segments"][0], json!(["straight", "curved", "straight", "straight"]));
|
||||
assert_eq!(q(&mut s)["paths"], before);
|
||||
let id = deckcraft_model::ShapeId(q(&mut s)["id"].as_u64().unwrap() as u32);
|
||||
let handles = |s: &Session, i| {
|
||||
let st = s.doc().unwrap();
|
||||
crate::cmd::points::slide_handles(&st.doc, &st.selection, st.shape(id).unwrap(), 0, i).unwrap()
|
||||
};
|
||||
let (out0, in1) = (handles(&s, 0).1.unwrap(), handles(&s, 1).0.unwrap());
|
||||
assert!((out0 - deckcraft_geom::Point::new(100.0 + 200.0 / 3.0, 100.0)).hypot() < 1e-3, "{out0:?}");
|
||||
assert!((in1 - deckcraft_geom::Point::new(100.0 + 400.0 / 3.0, 100.0)).hypot() < 1e-3, "{in1:?}");
|
||||
// Bend it, then Straight Segment drops its handles; the points stay where they are.
|
||||
s.execute("shape.moveHandle", &json!({"index": 1, "side": "in", "x": in1.x, "y": 60})).unwrap();
|
||||
let bent = q(&mut s)["paths"].clone();
|
||||
s.execute("shape.segmentType", &json!({"segment": 1, "type": "straight"})).unwrap();
|
||||
assert_eq!(q(&mut s)["segments"][0][1], "straight");
|
||||
assert_eq!((handles(&s, 0).1, handles(&s, 1).0), (None, None));
|
||||
assert_eq!(q(&mut s)["paths"], before);
|
||||
// Each change is one undo step.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(q(&mut s)["segments"][0][1], "curved");
|
||||
assert_eq!(q(&mut s)["paths"], bent);
|
||||
// The closing edge is segment 0.
|
||||
s.execute("shape.segmentType", &json!({"segment": 0, "type": "curved"})).unwrap();
|
||||
assert_eq!(q(&mut s)["segments"][0][0], "curved");
|
||||
// Asking for the type a segment already has is not an undo step.
|
||||
let depth = s.doc().unwrap().history.undo.len();
|
||||
s.execute("shape.segmentType", &json!({"segment": 2, "type": "straight"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), depth);
|
||||
assert!(s.execute("shape.segmentType", &json!({"segment": 9, "type": "curved"})).is_err());
|
||||
assert!(s.execute("shape.segmentType", &json!({"segment": 1, "type": "wavy"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_points_open_and_close_path() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap();
|
||||
s.execute("shape.editPoints", &json!({"on": true})).unwrap();
|
||||
let pts =
|
||||
|s: &mut Session| -> Vec<[f64; 2]> { serde_json::from_value(s.execute("shape.points", &json!({})).unwrap()["paths"][0].clone()).unwrap() };
|
||||
let d = |s: &Session| match &s.doc().unwrap().selected_shapes()[0].geom {
|
||||
deckcraft_model::Geom::Custom { paths } => paths[0].d.clone(),
|
||||
g => panic!("{g:?}"),
|
||||
};
|
||||
assert!(s.execute("shape.closePath", &json!({"index": 0})).is_err(), "the outline is closed already");
|
||||
// A point type remembered for handle drags belongs to the old numbering: opening forgets it.
|
||||
s.execute("shape.pointType", &json!({"index": 2, "type": "straight"})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.tool.vertex_kind.is_some());
|
||||
// Open Path at the bottom-right corner: the outline now starts and ends there, as two points.
|
||||
s.execute("shape.openPath", &json!({"index": 2})).unwrap();
|
||||
assert!(!d(&s).contains('Z'), "{}", d(&s));
|
||||
assert_eq!(s.tool.vertex_kind, None);
|
||||
let v = pts(&mut s);
|
||||
assert_eq!(v, vec![[300.0, 200.0], [100.0, 200.0], [100.0, 100.0], [300.0, 100.0], [300.0, 200.0]]);
|
||||
assert!(s.execute("shape.openPath", &json!({"index": 1})).is_err(), "the outline is open already");
|
||||
// The two ends move apart; Close Path joins them with a straight segment.
|
||||
s.execute("shape.movePoint", &json!({"index": 4, "x": 320, "y": 180})).unwrap();
|
||||
s.execute("shape.closePath", &json!({"index": 1})).unwrap();
|
||||
assert!(d(&s).ends_with('Z'), "{}", d(&s));
|
||||
assert_eq!(pts(&mut s).len(), 5);
|
||||
assert_eq!(s.execute("shape.points", &json!({})).unwrap()["segments"][0][0], "straight");
|
||||
// Ends that meet become one point again.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("shape.closePath", &json!({"index": 4})).unwrap();
|
||||
assert_eq!(pts(&mut s), vec![[300.0, 200.0], [100.0, 200.0], [100.0, 100.0], [300.0, 100.0]]);
|
||||
// Opening at the first point keeps the order.
|
||||
s.execute("shape.openPath", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(pts(&mut s), vec![[300.0, 200.0], [100.0, 200.0], [100.0, 100.0], [300.0, 100.0], [300.0, 200.0]]);
|
||||
assert!(s.execute("shape.openPath", &json!({"index": 9})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_points_right_click_picks_the_point_or_the_segment() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap();
|
||||
s.execute("shape.editPoints", &json!({"on": true})).unwrap();
|
||||
// The middle of the right edge: the segment ending at point 2.
|
||||
right_click(&mut s, 300.0, 150.0);
|
||||
assert_eq!((s.tool.edit_segment, s.tool.edit_vertex), (Some((0, 2)), None));
|
||||
right_click(&mut s, 300.0, 100.0);
|
||||
assert_eq!((s.tool.edit_segment, s.tool.edit_vertex), (None, Some((0, 1))));
|
||||
right_click(&mut s, 200.0, 150.0);
|
||||
assert_eq!((s.tool.edit_segment, s.tool.edit_vertex), (None, None));
|
||||
}
|
||||
|
||||
/// A right-click on a control handle keeps the point menu: the vertex stays picked.
|
||||
#[test]
|
||||
fn edit_points_right_click_on_a_handle_keeps_the_picked_vertex() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [100, 100, 200, 100]})).unwrap();
|
||||
s.execute("shape.editPoints", &json!({"on": true})).unwrap();
|
||||
// The right-most point of the ellipse.
|
||||
right_click(&mut s, 300.0, 150.0);
|
||||
let (path, index) = s.tool.edit_vertex.expect("the point is picked");
|
||||
let st = s.doc().unwrap();
|
||||
let id = s.tool.editing_points(st).unwrap();
|
||||
let handle = cmd::points::slide_handles(&st.doc, &st.selection, st.shape(id).unwrap(), path, index)
|
||||
.and_then(|(hin, hout, _)| hout.or(hin))
|
||||
.expect("a smooth point has a handle");
|
||||
let r = right_click(&mut s, handle.x, handle.y);
|
||||
assert_eq!(r["hit"]["kind"], "handle", "{r}");
|
||||
assert_eq!((s.tool.edit_segment, s.tool.edit_vertex), (None, Some((path, index))));
|
||||
}
|
||||
|
||||
/// #392: a `.deckcraft` whose embedded picture is damaged opens as an untitled "(Repaired)"
|
||||
/// copy with a message, so Save can't overwrite the original with the empty media.
|
||||
#[test]
|
||||
@@ -3098,6 +3279,70 @@ fn dragging_the_frame_of_edited_text_moves_the_box() {
|
||||
assert_eq!(text_box_xfrm(&s, id), x);
|
||||
}
|
||||
|
||||
/// Press and release at `at` without moving.
|
||||
fn click_at(s: &mut Session, at: (f64, f64)) {
|
||||
let ev = |kind| PointerEvent { kind, x: at.0, y: at.1, mods: Mods::default(), tol: 3.0, pressure: None };
|
||||
s.pointer(ev(PointerKind::Down)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_the_frame_of_a_text_box_selects_it_whole() {
|
||||
let (mut s, id, x) = editing_text_box(0.0);
|
||||
let sid = deckcraft_model::ShapeId(id as u32);
|
||||
let undo_before = s.doc().unwrap().history.undo.len();
|
||||
// Leave editing and select nothing: a click on empty slide.
|
||||
click_at(&mut s, (20.0, 20.0));
|
||||
assert!(s.doc().unwrap().selection.shapes.is_empty());
|
||||
|
||||
// Edges and corner bands, inside and just outside the box, select it but don't start typing.
|
||||
for (lx, ly) in [(x.w / 4.0, 1.0), (x.w / 4.0, -2.0), (x.w * 0.75, x.h - 1.0), (1.0, x.h / 2.0), (x.w + 2.0, x.h / 2.0)] {
|
||||
click_at(&mut s, (20.0, 20.0));
|
||||
click_at(&mut s, on_box(&x, lx, ly));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.selection.shapes, vec![sid], "({lx}, {ly})");
|
||||
assert!(st.selection.text.is_none(), "({lx}, {ly}) entered text");
|
||||
}
|
||||
|
||||
// Dragging the frame moves the box; the inside of an unselected box still takes the caret.
|
||||
click_at(&mut s, (20.0, 20.0));
|
||||
let from = on_box(&x, x.w / 4.0, 1.0);
|
||||
drag_gesture(&mut s, from, (from.0 + 30.0, from.1 + 20.0));
|
||||
let moved = text_box_xfrm(&s, id);
|
||||
assert!((moved.x - (x.x + 30.0)).abs() < 1e-9 && (moved.y - (x.y + 20.0)).abs() < 1e-9, "{moved:?}");
|
||||
click_at(&mut s, (20.0, 20.0));
|
||||
click_at(&mut s, on_box(&moved, moved.w / 4.0, moved.h / 2.0));
|
||||
assert!(s.doc().unwrap().selection.text.is_some());
|
||||
|
||||
// The frame also deletes: select by frame, press Delete.
|
||||
click_at(&mut s, (20.0, 20.0));
|
||||
click_at(&mut s, on_box(&moved, moved.w / 4.0, 1.0));
|
||||
s.execute("edit.delete", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().shape(sid).is_none());
|
||||
assert!(s.doc().unwrap().history.undo.len() > undo_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_the_frame_of_a_selected_plain_shape_still_takes_the_caret() {
|
||||
let mut s = session();
|
||||
s.prefs.snap_to_grid = false;
|
||||
s.prefs.smart_guides = false;
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let id =
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [200.0, 150.0, 240.0, 60.0], "text": "Hello"})).unwrap()["id"].as_u64().unwrap();
|
||||
let sid = deckcraft_model::ShapeId(id as u32);
|
||||
let x = text_box_xfrm(&s, id);
|
||||
// The first click selects an ordinary shape, the second puts the caret, as before; the frame
|
||||
// rule is for text boxes and placeholders only.
|
||||
click_at(&mut s, (20.0, 20.0));
|
||||
click_at(&mut s, on_box(&x, x.w / 4.0, x.h / 2.0));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.selection.shapes, vec![sid]);
|
||||
assert!(st.selection.text.is_none());
|
||||
click_at(&mut s, on_box(&x, x.w / 4.0, 1.0));
|
||||
assert!(s.doc().unwrap().selection.text.is_some(), "frame click on a selected plain shape lost the caret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_inside_edited_text_selects_text() {
|
||||
let (mut s, id, x) = editing_text_box(0.0);
|
||||
@@ -3893,6 +4138,32 @@ fn table_insert_below_or_right_of_a_huge_index_appends_at_the_end() {
|
||||
assert_eq!(t.cell(0, 1).unwrap().text.text(), "b", "the original columns keep their place");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_commands_clamp_a_hostile_text_selection() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [u64::MAX, 0]})).unwrap();
|
||||
s.execute("text.selectParagraph", &json!({"at": [u64::MAX, 0]})).unwrap();
|
||||
s.execute("format.alignCenter", &json!({})).unwrap();
|
||||
s.execute("format.case", &json!({"mode": "upper"})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "A"})).unwrap();
|
||||
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
assert!(t.caret.0 < 1_000, "the caret stayed inside the body: {t:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_commands_clamp_a_direct_hostile_text_selection() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [u64::MAX, 0]})).unwrap();
|
||||
s.execute("format.alignCenter", &json!({})).unwrap();
|
||||
s.execute("format.case", &json!({"mode": "upper"})).unwrap();
|
||||
let body = s.doc().unwrap().shape(deckcraft_model::ShapeId(title)).unwrap().text.as_ref().unwrap();
|
||||
assert_eq!(body.paragraphs[0].props.align, Some(deckcraft_model::text::Align::Center));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_column_width_of_a_huge_column_is_ignored() {
|
||||
let mut s = session();
|
||||
@@ -4403,3 +4674,53 @@ fn duplicating_a_moved_copy_repeats_the_move() {
|
||||
assert_eq!(boxed(&mut s, f), (340.0 + off, 100.0 + off));
|
||||
assert_eq!(s.doc().unwrap().doc.validate(), Vec::<String>::new());
|
||||
}
|
||||
|
||||
fn chart_of(s: &Session, id: u64) -> deckcraft_model::Chart {
|
||||
match &s.doc().unwrap().shape(deckcraft_model::ShapeId(id as u32)).unwrap().kind {
|
||||
deckcraft_model::ShapeKind::Chart(c) => (**c).clone(),
|
||||
k => panic!("not a chart: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A chart's text (title, axes, legend, labels) can be coloured: `chart.options {textColor}` sets
|
||||
/// it, `null` goes back to the default, and a colour that isn't one is refused.
|
||||
#[test]
|
||||
fn chart_options_text_color_sets_clears_and_refuses() {
|
||||
let mut s = session();
|
||||
let id = s.execute("insert.chart", &json!({"type": "column", "title": "Sales"})).unwrap()["id"].as_u64().unwrap();
|
||||
assert_eq!(chart_of(&s, id).text_color, None);
|
||||
s.execute("chart.options", &json!({"id": id, "textColor": "#FF0000"})).unwrap();
|
||||
assert_eq!(chart_of(&s, id).text_color, Some(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))));
|
||||
s.execute("chart.options", &json!({"id": id, "textColor": "accent2"})).unwrap();
|
||||
assert_eq!(chart_of(&s, id).text_color, Some(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent2)));
|
||||
let e = s.execute("chart.options", &json!({"id": id, "textColor": "reddish"})).unwrap_err().to_string();
|
||||
assert!(e.contains("textColor"), "{e}");
|
||||
assert_eq!(chart_of(&s, id).text_color, Some(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent2)), "refused: unchanged");
|
||||
s.execute("chart.options", &json!({"id": id, "textColor": null})).unwrap();
|
||||
assert_eq!(chart_of(&s, id).text_color, None);
|
||||
}
|
||||
|
||||
/// Font Color on a selected chart colours the chart's text (it used to answer and change nothing).
|
||||
#[test]
|
||||
fn format_color_on_a_selected_chart_sets_its_text_color() {
|
||||
let mut s = session();
|
||||
let id = s.execute("insert.chart", &json!({"type": "column", "title": "Sales"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("edit.select", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("format.color", &json!({"color": "accent2"})).unwrap();
|
||||
assert_eq!(chart_of(&s, id).text_color, Some(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_hide_and_layout_reject_invalid_index() {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
s.execute("slide.selectSlides", &json!({"indices": [0]})).unwrap();
|
||||
for bad in [json!(-1), json!(1.5)] {
|
||||
let e = s.execute("slide.hide", &json!({"index": bad})).unwrap_err().to_string();
|
||||
assert!(e.contains("not a valid index"), "{e}");
|
||||
let e = s.execute("slide.layout", &json!({"layout": "blank", "index": bad})).unwrap_err().to_string();
|
||||
assert!(e.contains("not a valid index"), "{e}");
|
||||
}
|
||||
assert!(s.doc().unwrap().doc.slides.iter().all(|x| !x.hidden));
|
||||
}
|
||||
|
||||
@@ -266,6 +266,9 @@ pub struct ToolState {
|
||||
pub edit_points: Option<ShapeId>,
|
||||
/// The vertex (path, index) whose control handles show in Edit Points.
|
||||
pub edit_vertex: Option<(usize, usize)>,
|
||||
/// The segment (path, index of the vertex ending it) last right-clicked in Edit Points, for
|
||||
/// the segment menu.
|
||||
pub edit_segment: Option<(usize, usize)>,
|
||||
/// The point type last chosen for a vertex (path, index, type): handle drags follow it.
|
||||
pub vertex_kind: Option<(usize, usize, String)>,
|
||||
/// Drawing guides are shown (View › Guides): they can be dragged and shapes snap to them.
|
||||
@@ -694,7 +697,12 @@ impl Session {
|
||||
// placeholders take the caret straight away like PowerPoint. A click in a selected
|
||||
// table puts the caret in the clicked cell.
|
||||
let table = st.shape(id).is_some_and(|s| matches!(s.kind, ShapeKind::Table(_)));
|
||||
let enter_text = (!toggle && ((text && (selected || is_text_first(st, id))) || (table && selected))).then_some((id, p));
|
||||
// A click on the frame band of a text box selects the box whole, as PowerPoint
|
||||
// does, so it can be moved, duplicated or deleted; only the inside takes the caret.
|
||||
let on_frame = text
|
||||
&& is_text_first(st, id)
|
||||
&& st.shape(id).is_some_and(|s| edit_zone(&xfrm_of(&st.doc, &st.selection, s), p, tol) == EditZone::Frame);
|
||||
let enter_text = (!toggle && !on_frame && ((text && (selected || is_text_first(st, id))) || (table && selected))).then_some((id, p));
|
||||
let ids2 = ids.clone();
|
||||
self.select(|_, sel| {
|
||||
sel.shapes = ids2;
|
||||
@@ -1243,6 +1251,12 @@ impl Session {
|
||||
) => Some(*id),
|
||||
None => None,
|
||||
};
|
||||
// Edit Points: the point or else the segment under the pointer picks the menu.
|
||||
let picked = self.tool.editing_points(st).and_then(|eid| st.shape(eid)).map(|sh| match &hit {
|
||||
Some(Hit::Vertex { path, index, .. }) => (Some((*path, *index)), None),
|
||||
Some(Hit::Handle { .. }) => (None, None),
|
||||
_ => (None, cmd::points::segment_at(&st.doc, &st.selection, sh, p, tol * 1.6).map(|(path, seg, _)| (path, seg))),
|
||||
});
|
||||
if !id.is_some_and(|id| st.selection.shapes.contains(&id)) {
|
||||
self.select(|_, sel| {
|
||||
sel.text = None;
|
||||
@@ -1250,6 +1264,13 @@ impl Session {
|
||||
sel.shapes = id.into_iter().collect();
|
||||
})?;
|
||||
}
|
||||
if let Some((vertex, segment)) = picked {
|
||||
// A control handle belongs to the picked point: its menu stays.
|
||||
if !matches!(hit, Some(Hit::Handle { .. })) {
|
||||
self.tool.edit_vertex = vertex;
|
||||
}
|
||||
self.tool.edit_segment = segment;
|
||||
}
|
||||
let st = self.doc()?;
|
||||
Ok(json!({
|
||||
"hit": serde_json::to_value(&hit).unwrap_or_default(),
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
//! #844: a paragraph-by-paragraph build read from a PPTX file takes the clicks PowerPoint gives
|
||||
//! it. The file lists one effect per paragraph, each with its own start (On Click or With
|
||||
//! Previous); the show must not ask for more clicks, or fewer, than those effects.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_anim::{ShowAction, ShowState};
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::anim::{AnimStart, Animation, TextBuild};
|
||||
use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{LayoutType, Presentation, Shape, ShapeId, defaults};
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
fn shape(p: &mut Presentation, name: &str, text: &str, y: f64) -> Shape {
|
||||
Shape {
|
||||
id: ShapeId(p.alloc_id().expect("id")),
|
||||
name: name.into(),
|
||||
xfrm: Some(Xfrm::new(100.0, y, 600.0, 150.0)),
|
||||
text: Some(TextBody::from_text(text)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Two slides: "Body" (paragraphs `body`, at `levels`) and "Picture" on the first, nothing on
|
||||
/// the second, saved as PPTX.
|
||||
fn saved(body: &str, levels: &[u8]) -> Vec<u8> {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
p.slides.clear();
|
||||
let mut b = shape(&mut p, "Body", body, 50.0);
|
||||
if let Some(t) = b.text.as_mut() {
|
||||
for (para, lvl) in t.paragraphs.iter_mut().zip(levels) {
|
||||
para.level = *lvl;
|
||||
}
|
||||
}
|
||||
let pic = shape(&mut p, "Picture", "Picture", 300.0);
|
||||
let anims = vec![
|
||||
Animation { shape: b.id, text_build: TextBuild::ByParagraph, start: AnimStart::OnClick, duration_ms: 500, ..Default::default() },
|
||||
Animation { shape: pic.id, start: AnimStart::WithPrevious, duration_ms: 500, ..Default::default() },
|
||||
];
|
||||
for (shapes, animations) in [(vec![b, pic], anims), (vec![], vec![])] {
|
||||
let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default();
|
||||
let mut s = defaults::new_slide(&mut p, layout).expect("slide");
|
||||
s.shapes = shapes;
|
||||
s.animations = animations;
|
||||
p.slides.push(Arc::new(s));
|
||||
}
|
||||
deckcraft_pptx::export(&p).expect("export")
|
||||
}
|
||||
|
||||
fn spid(xml: &str, name: &str) -> String {
|
||||
let at = xml.find(&format!(r#"name="{name}""#)).expect("shape");
|
||||
let head = &xml[..at];
|
||||
let id = head.rfind(r#"id=""#).expect("id") + 4;
|
||||
head[id..].split('"').next().expect("spid").to_string()
|
||||
}
|
||||
|
||||
/// One effect node as PowerPoint writes it: Fade in on `spid` (paragraph `para`).
|
||||
fn effect(ids: &mut u32, spid: &str, para: Option<u32>, node: &str) -> String {
|
||||
let mut id = || {
|
||||
*ids += 1;
|
||||
*ids
|
||||
};
|
||||
let tgt = match para {
|
||||
Some(p) => format!(r#"<p:spTgt spid="{spid}"><p:txEl><p:pRg st="{p}" end="{p}"/></p:txEl></p:spTgt>"#),
|
||||
None => format!(r#"<p:spTgt spid="{spid}"/>"#),
|
||||
};
|
||||
format!(
|
||||
r#"<p:par><p:cTn id="{}" presetID="10" presetClass="entr" presetSubtype="0" fill="hold" grpId="0" nodeType="{node}"><p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst><p:set><p:cBhvr><p:cTn id="{}" dur="1" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst></p:cTn><p:tgtEl>{tgt}</p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="visible"/></p:to></p:set><p:animEffect transition="in" filter="fade"><p:cBhvr><p:cTn id="{}" dur="500"/><p:tgtEl>{tgt}</p:tgtEl></p:cBhvr></p:animEffect></p:childTnLst></p:cTn></p:par>"#,
|
||||
id(),
|
||||
id(),
|
||||
id()
|
||||
)
|
||||
}
|
||||
|
||||
/// A click group holding `effects`.
|
||||
fn click(ids: &mut u32, effects: &[String]) -> String {
|
||||
*ids += 2;
|
||||
format!(
|
||||
r#"<p:par><p:cTn id="{}" fill="hold"><p:stCondLst><p:cond delay="indefinite"/></p:stCondLst><p:childTnLst><p:par><p:cTn id="{}" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst>{}</p:childTnLst></p:cTn></p:par></p:childTnLst></p:cTn></p:par>"#,
|
||||
*ids - 1,
|
||||
*ids,
|
||||
effects.concat()
|
||||
)
|
||||
}
|
||||
|
||||
/// `bytes` with the first slide's timing replaced by `clicks` (built with `effect` and `click`
|
||||
/// from the spids of Body and Picture), Body's build "By paragraph".
|
||||
fn with_timing(bytes: &[u8], clicks: impl Fn(&mut u32, &str, &str) -> Vec<String>) -> Vec<u8> {
|
||||
let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for i in 0..src.len() {
|
||||
let mut f = src.by_index(i).expect("entry");
|
||||
let name = f.name().to_string();
|
||||
let mut data = Vec::new();
|
||||
f.read_to_end(&mut data).expect("read");
|
||||
if name == "ppt/slides/slide1.xml" {
|
||||
let xml = String::from_utf8(data).expect("utf8");
|
||||
let (body, pic) = (spid(&xml, "Body"), spid(&xml, "Picture"));
|
||||
let mut ids = 2;
|
||||
let main = clicks(&mut ids, &body, &pic).concat();
|
||||
let timing = format!(
|
||||
r#"<p:timing><p:tnLst><p:par><p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot"><p:childTnLst><p:seq concurrent="1" nextAc="seek"><p:cTn id="2" dur="indefinite" nodeType="mainSeq"><p:childTnLst>{main}</p:childTnLst></p:cTn><p:prevCondLst><p:cond evt="onPrev" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:prevCondLst><p:nextCondLst><p:cond evt="onNext" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:nextCondLst></p:seq></p:childTnLst></p:cTn></p:par></p:tnLst><p:bldLst><p:bldP spid="{body}" grpId="0" build="p"/><p:bldP spid="{pic}" grpId="0" animBg="1"/></p:bldLst></p:timing>"#
|
||||
);
|
||||
let a = xml.find("<p:timing>").expect("timing");
|
||||
let b = xml.find("</p:timing>").expect("timing end") + "</p:timing>".len();
|
||||
data = format!("{}{timing}{}", &xml[..a], &xml[b..]).into_bytes();
|
||||
}
|
||||
out.start_file(name, SimpleFileOptions::default()).expect("start");
|
||||
out.write_all(&data).expect("write");
|
||||
}
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
/// Clicks (Next with nothing playing) from the start of the first slide to the second one.
|
||||
fn clicks_to_second_slide(pres: &Presentation) -> usize {
|
||||
let mut s = ShowState::new(pres);
|
||||
for n in 1..=50 {
|
||||
match s.next(pres) {
|
||||
ShowAction::PlayStep(_) => {
|
||||
// The step clock runs out before the next click.
|
||||
s.step_done(pres);
|
||||
}
|
||||
ShowAction::Slide(1) => return n,
|
||||
a => panic!("unexpected {a:?}"),
|
||||
}
|
||||
}
|
||||
usize::MAX
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paragraph_build_split_by_another_shapes_effect_takes_powerpoints_clicks() {
|
||||
// As PowerPoint writes "Body: By paragraph, Picture: With Previous after the first
|
||||
// paragraph": click 1 = Body ¶0 + Picture, click 2 = Body ¶1, click 3 = Body ¶2.
|
||||
let bytes = with_timing(&saved("One\nTwo\nThree", &[0, 0, 0]), |ids, body, pic| {
|
||||
let e0 = effect(ids, body, Some(0), "clickEffect");
|
||||
let ep = effect(ids, pic, None, "withEffect");
|
||||
let e1 = effect(ids, body, Some(1), "clickEffect");
|
||||
let e2 = effect(ids, body, Some(2), "clickEffect");
|
||||
vec![click(ids, &[e0, ep]), click(ids, &[e1]), click(ids, &[e2])]
|
||||
});
|
||||
let pres = deckcraft_pptx::import(&bytes).expect("import");
|
||||
assert_eq!(ShowState::steps(&pres, 0), 3, "three click steps, as in the file");
|
||||
assert_eq!(clicks_to_second_slide(&pres), 4, "three builds and one click to move on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_build_by_second_level_paragraphs_takes_a_click_per_paragraph() {
|
||||
// "By 2nd level paragraphs": the heading and each sub-point have their own click.
|
||||
// Picture comes in with the last one, so nothing splits Body's build.
|
||||
let bytes = with_timing(&saved("Question\nFirst\nSecond", &[0, 1, 1]), |ids, body, pic| {
|
||||
let e0 = effect(ids, body, Some(0), "clickEffect");
|
||||
let e1 = effect(ids, body, Some(1), "clickEffect");
|
||||
let e2 = effect(ids, body, Some(2), "clickEffect");
|
||||
let ep = effect(ids, pic, None, "withEffect");
|
||||
vec![click(ids, &[e0]), click(ids, &[e1]), click(ids, &[e2, ep])]
|
||||
});
|
||||
let pres = deckcraft_pptx::import(&bytes).expect("import");
|
||||
assert_eq!(ShowState::steps(&pres, 0), 3, "three click steps, as in the file");
|
||||
}
|
||||
|
||||
/// The first slide's XML in a saved PPTX.
|
||||
fn slide_xml(bytes: &[u8]) -> String {
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut xml = String::new();
|
||||
zip.by_name("ppt/slides/slide1.xml").expect("slide").read_to_string(&mut xml).expect("read");
|
||||
xml
|
||||
}
|
||||
|
||||
/// Paragraph and start of each animation on the first slide.
|
||||
fn effects(pres: &Presentation) -> Vec<(Option<u32>, AnimStart, TextBuild)> {
|
||||
pres.slides[0].animations.iter().map(|a| (a.paragraph, a.start, a.text_build)).collect()
|
||||
}
|
||||
|
||||
/// "By 1st level paragraphs" as PowerPoint writes it: a sub-point comes in with its parent.
|
||||
fn first_level_build() -> Vec<u8> {
|
||||
with_timing(&saved("One\nSub\nTwo", &[0, 1, 0]), |ids, body, pic| {
|
||||
let e0 = effect(ids, body, Some(0), "clickEffect");
|
||||
let e1 = effect(ids, body, Some(1), "withEffect");
|
||||
let e2 = effect(ids, body, Some(2), "clickEffect");
|
||||
let ep = effect(ids, pic, None, "withEffect");
|
||||
vec![click(ids, &[e0, e1]), click(ids, &[e2, ep])]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_first_level_build_still_reads_as_one_by_paragraph_animation() {
|
||||
let pres = deckcraft_pptx::import(&first_level_build()).expect("import");
|
||||
assert_eq!(effects(&pres)[0], (None, AnimStart::OnClick, TextBuild::ByParagraph));
|
||||
assert_eq!(pres.slides[0].animations.len(), 2, "Body's build and Picture");
|
||||
assert_eq!(ShowState::steps(&pres, 0), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_by_paragraph_build_with_sub_points_survives_saving() {
|
||||
let pres = deckcraft_pptx::import(&first_level_build()).expect("import");
|
||||
let again = deckcraft_pptx::import(&deckcraft_pptx::export(&pres).expect("export")).expect("reimport");
|
||||
assert_eq!(effects(&again), effects(&pres));
|
||||
assert_eq!(ShowState::steps(&again, 0), 2, "the sub-point still comes in with its parent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_effects_save_as_powerpoint_writes_them_and_read_back_unchanged() {
|
||||
let bytes = with_timing(&saved("One\nTwo\nThree", &[0, 0, 0]), |ids, body, pic| {
|
||||
let e0 = effect(ids, body, Some(0), "clickEffect");
|
||||
let ep = effect(ids, pic, None, "withEffect");
|
||||
let e1 = effect(ids, body, Some(1), "clickEffect");
|
||||
let e2 = effect(ids, body, Some(2), "clickEffect");
|
||||
vec![click(ids, &[e0, ep]), click(ids, &[e1]), click(ids, &[e2])]
|
||||
});
|
||||
let pres = deckcraft_pptx::import(&bytes).expect("import");
|
||||
let saved = deckcraft_pptx::export(&pres).expect("export");
|
||||
let xml = slide_xml(&saved);
|
||||
let body = spid(&xml, "Body");
|
||||
let blds: Vec<&str> = xml.split("<p:bldP ").skip(1).filter(|b| b.starts_with(&format!(r#"spid="{body}""#))).collect();
|
||||
// ¶0 is its own build (Picture sits between), ¶1 and ¶2 share one.
|
||||
assert_eq!(blds.len(), 2, "{blds:?}");
|
||||
assert!(blds.iter().all(|b| b.contains(r#"build="p""#) && !b.contains("animBg")), "{blds:?}");
|
||||
let again = deckcraft_pptx::import(&saved).expect("reimport");
|
||||
assert_eq!(effects(&again), effects(&pres));
|
||||
assert_eq!(ShowState::steps(&again, 0), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_by_paragraph_build_on_blank_text_saves_as_a_whole_shape_effect() {
|
||||
let xml = slide_xml(&saved(" ", &[0]));
|
||||
assert!(!xml.contains(r#"build="p""#), "no paragraph build without a paragraph to animate");
|
||||
assert!(!xml.contains("<p:txEl>"), "the effect targets the whole shape");
|
||||
}
|
||||
|
||||
/// Body's three paragraphs under `build="p"`, effects in the order `effects` gives them, then
|
||||
/// Picture with the last one.
|
||||
fn three_paragraphs(effects: impl Fn(&mut u32, &str) -> Vec<String>) -> Presentation {
|
||||
let bytes = with_timing(&saved("One\nTwo\nThree", &[0, 0, 0]), |ids, body, pic| {
|
||||
let mut clicks: Vec<String> = effects(ids, body).iter().map(|e| click(ids, std::slice::from_ref(e))).collect();
|
||||
let ep = effect(ids, pic, None, "clickEffect");
|
||||
clicks.push(click(ids, &[ep]));
|
||||
clicks
|
||||
});
|
||||
deckcraft_pptx::import(&bytes).expect("import")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reversed_build_keeps_its_order() {
|
||||
let pres = three_paragraphs(|ids, body| (0..3).rev().map(|p| effect(ids, body, Some(p), "clickEffect")).collect());
|
||||
let paras: Vec<Option<u32>> = pres.slides[0].animations.iter().map(|a| a.paragraph).collect();
|
||||
assert_eq!(paras, vec![Some(2), Some(1), Some(0), None], "last paragraph first, as in the file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paragraph_with_its_own_duration_keeps_its_own_effect() {
|
||||
let pres = three_paragraphs(|ids, body| {
|
||||
(0..3)
|
||||
.map(|p| {
|
||||
let e = effect(ids, body, Some(p), "clickEffect");
|
||||
if p == 1 { e.replace(r#"dur="500""#, r#"dur="900""#) } else { e }
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let durs: Vec<(Option<u32>, u32)> = pres.slides[0].animations.iter().map(|a| (a.paragraph, a.duration_ms)).collect();
|
||||
assert_eq!(durs, vec![(Some(0), 500), (Some(1), 900), (Some(2), 500), (None, 500)]);
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
mod chart_parts;
|
||||
mod finite;
|
||||
pub mod rtf;
|
||||
|
||||
#[cfg(test)]
|
||||
mod chart_parts_tests;
|
||||
@@ -661,6 +662,7 @@ pub fn slides_to_outline(p: &Presentation) -> String {
|
||||
if let ShapeKind::Table(t) = &sh.kind {
|
||||
for row in &t.rows {
|
||||
let cells: Vec<String> = row.cells.iter().map(|c| c.text.text().replace(['\u{b}', '\n'], " ")).collect();
|
||||
out.push('\t');
|
||||
out.push_str(&cells.join("\t"));
|
||||
out.push('\n');
|
||||
}
|
||||
@@ -1100,3 +1102,33 @@ mod tests {
|
||||
assert!(out.contains("Cell A2\tCell B2"), "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_table_rows_are_indented_and_do_not_become_slides() {
|
||||
use deckcraft_model::table::{Cell, Row, Table};
|
||||
use deckcraft_model::{Shape, ShapeKind, Xfrm};
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap();
|
||||
let mut s = defaults::new_slide(&mut p, layout).expect("slide");
|
||||
for sh in s.shapes.iter_mut().filter(|sh| sh.ph_type().is_some_and(|k| k.is_title())) {
|
||||
sh.text = Some(TextBody::from_text("Slide title"));
|
||||
}
|
||||
let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() };
|
||||
for texts in [["A1", "B1"], ["A2", "B2"]] {
|
||||
let cells: Vec<Cell> = texts.iter().map(|t| Cell { text: TextBody::from_text(t), ..Default::default() }).collect();
|
||||
table.rows.push(Row { height: 30.0, cells });
|
||||
}
|
||||
let mut tbl = Shape { xfrm: Some(Xfrm::new(0.0, 100.0, 400.0, 60.0)), ..Default::default() };
|
||||
tbl.kind = ShapeKind::Table(table);
|
||||
s.shapes.push(tbl);
|
||||
p.slides = vec![std::sync::Arc::new(s)];
|
||||
|
||||
let out = slides_to_outline(&p);
|
||||
let mut q = defaults::new_presentation(None);
|
||||
q.slides.clear();
|
||||
assert_eq!(outline_to_slides(&mut q, &out), 1, "table rows became slides: {out:?}");
|
||||
assert_eq!(q.slides.len(), 1, "{out:?}");
|
||||
let body = q.slides[0].shapes.iter().filter_map(|sh| sh.text.as_ref()).map(|t| t.text()).collect::<Vec<_>>().join("\n");
|
||||
assert!(body.contains("A1\tB1"), "{body:?}");
|
||||
assert!(body.contains("A2\tB2"), "{body:?}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
//! Outline / RTF: PowerPoint's "Outline/RTF" save type, and reading such a file back as slides.
|
||||
//!
|
||||
//! The export writes one paragraph per slide title (outline level 0, bold, larger) and one per
|
||||
//! body paragraph (outline level = indent + 1, indented), keeping bold, italic and underline.
|
||||
//! The import reads paragraphs and their `\outlinelevel` (or, failing that, `\li` indent) into
|
||||
//! the tab-indented outline [`crate::outline_to_slides`] understands.
|
||||
|
||||
use deckcraft_model::Presentation;
|
||||
use deckcraft_model::text::{Paragraph, RunKind};
|
||||
|
||||
/// RTF-escape `s` (non-ASCII as `\uN?`, UTF-16, signed as RTF wants).
|
||||
fn escape(out: &mut String, s: &str) {
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' | '{' | '}' => {
|
||||
out.push('\\');
|
||||
out.push(c);
|
||||
}
|
||||
'\t' => out.push_str("\\tab "),
|
||||
'\u{b}' | '\n' => out.push_str("\\line "),
|
||||
c if c.is_ascii() && !c.is_ascii_control() => out.push(c),
|
||||
c if c.is_ascii() => {}
|
||||
c => {
|
||||
let mut buf = [0u16; 2];
|
||||
for u in c.encode_utf16(&mut buf) {
|
||||
out.push_str(&format!("\\u{}?", *u as i16));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn runs(out: &mut String, p: &Paragraph) {
|
||||
for r in &p.runs {
|
||||
if matches!(r.kind, RunKind::Break) {
|
||||
out.push_str("\\line ");
|
||||
continue;
|
||||
}
|
||||
let (b, i, u) = (r.props.bold == Some(true), r.props.italic == Some(true), r.props.underline.as_deref().is_some_and(|u| u != "none"));
|
||||
if !(b || i || u) {
|
||||
escape(out, &r.text);
|
||||
continue;
|
||||
}
|
||||
out.push('{');
|
||||
out.push_str(if b { "\\b" } else { "" });
|
||||
out.push_str(if i { "\\i" } else { "" });
|
||||
out.push_str(if u { "\\ul" } else { "" });
|
||||
out.push(' ');
|
||||
escape(out, &r.text);
|
||||
out.push('}');
|
||||
}
|
||||
}
|
||||
|
||||
/// The presentation's outline (slide titles and body text) as RTF.
|
||||
pub fn slides_to_rtf(p: &Presentation) -> String {
|
||||
let mut out = String::from("{\\rtf1\\ansi\\ansicpg1252\\deff0\\uc1{\\fonttbl{\\f0\\fswiss Liberation Sans;}}\n");
|
||||
for s in &p.slides {
|
||||
let title = s.shapes.iter().find(|sh| sh.ph_type().is_some_and(|k| k.is_title())).and_then(|sh| sh.text.as_ref());
|
||||
out.push_str("\\pard\\outlinelevel0\\sb240\\b\\fs32 ");
|
||||
match title.and_then(|t| t.paragraphs.iter().find(|p| !p.is_empty())) {
|
||||
Some(tp) => runs(&mut out, tp),
|
||||
None => escape(&mut out, &s.title()),
|
||||
}
|
||||
out.push_str("\\b0\\par\n");
|
||||
for sh in &s.shapes {
|
||||
if sh.ph_type().is_some_and(|k| k.is_title()) {
|
||||
continue;
|
||||
}
|
||||
let Some(t) = &sh.text else { continue };
|
||||
for para in t.paragraphs.iter().filter(|p| !p.is_empty()) {
|
||||
let lvl = para.level.min(8) as u32;
|
||||
out.push_str(&format!("\\pard\\outlinelevel{}\\li{}\\fs24 ", lvl + 1, 360 * (lvl + 1)));
|
||||
runs(&mut out, para);
|
||||
out.push_str("\\par\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str("}\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `bytes` look like RTF.
|
||||
pub fn sniff_rtf(bytes: &[u8]) -> bool {
|
||||
bytes.trim_ascii_start().starts_with(b"{\\rtf")
|
||||
}
|
||||
|
||||
/// Destinations whose text is not document text.
|
||||
const SKIP: &[&str] = &[
|
||||
"fonttbl",
|
||||
"colortbl",
|
||||
"stylesheet",
|
||||
"info",
|
||||
"pict",
|
||||
"header",
|
||||
"footer",
|
||||
"footnote",
|
||||
"listtable",
|
||||
"listoverridetable",
|
||||
"generator",
|
||||
"xmlnstbl",
|
||||
"themedata",
|
||||
"datastore",
|
||||
"latentstyles",
|
||||
"rsidtbl",
|
||||
"object",
|
||||
"fldinst",
|
||||
];
|
||||
|
||||
/// `i + n`, or one character on when that would land inside a multi-byte character (a fallback
|
||||
/// run after `\uN` or `\'hh` may be followed by non-ASCII text).
|
||||
fn advance(rtf: &str, i: usize, n: usize) -> usize {
|
||||
if rtf.is_char_boundary(i + n) { i + n } else { i + rtf.get(i..).and_then(|r| r.chars().next()).map_or(1, char::len_utf8) }
|
||||
}
|
||||
|
||||
/// Read an RTF outline into tab-indented outline text: a paragraph's outline level (or its left
|
||||
/// indent in 0.25" steps) becomes its number of tabs.
|
||||
pub fn rtf_to_outline(rtf: &str) -> String {
|
||||
#[derive(Clone, Copy)]
|
||||
struct St {
|
||||
skip: bool,
|
||||
uc: usize,
|
||||
}
|
||||
let b = rtf.as_bytes();
|
||||
let mut stack: Vec<St> = vec![];
|
||||
let mut st = St { skip: false, uc: 1 };
|
||||
let (mut level, mut indent): (Option<u32>, i64) = (None, 0);
|
||||
let mut line = String::new();
|
||||
let mut out = String::new();
|
||||
let mut pending_skip = 0usize;
|
||||
let mut i = 0;
|
||||
let flush = |line: &mut String, level: Option<u32>, indent: i64, out: &mut String| {
|
||||
let text = line.trim();
|
||||
if !text.is_empty() {
|
||||
let n = level.map(|l| l as i64).unwrap_or(indent.saturating_add(180) / 360).clamp(0, 9) as usize;
|
||||
out.push_str(&"\t".repeat(n));
|
||||
out.push_str(text);
|
||||
out.push('\n');
|
||||
}
|
||||
line.clear();
|
||||
};
|
||||
while i < b.len() {
|
||||
let c = b[i];
|
||||
match c {
|
||||
b'{' => {
|
||||
stack.push(st);
|
||||
i += 1;
|
||||
// `{\*\dest …}`: an ignorable destination.
|
||||
if b[i..].starts_with(b"\\*") {
|
||||
st.skip = true;
|
||||
}
|
||||
}
|
||||
b'}' => {
|
||||
st = stack.pop().unwrap_or(St { skip: false, uc: 1 });
|
||||
i += 1;
|
||||
}
|
||||
b'\\' => {
|
||||
i += 1;
|
||||
let Some(&n) = b.get(i) else { break };
|
||||
if n.is_ascii_alphabetic() {
|
||||
let s = i;
|
||||
while i < b.len() && b[i].is_ascii_alphabetic() {
|
||||
i += 1;
|
||||
}
|
||||
let word = &rtf[s..i];
|
||||
let ns = i;
|
||||
if i < b.len() && (b[i] == b'-' || b[i].is_ascii_digit()) {
|
||||
i += 1;
|
||||
while i < b.len() && b[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let arg: Option<i64> = rtf[ns..i].parse().ok();
|
||||
if i < b.len() && b[i] == b' ' {
|
||||
i += 1;
|
||||
}
|
||||
if pending_skip > 0 {
|
||||
pending_skip -= 1;
|
||||
continue;
|
||||
}
|
||||
if SKIP.contains(&word) {
|
||||
st.skip = true;
|
||||
}
|
||||
if st.skip {
|
||||
continue;
|
||||
}
|
||||
match word {
|
||||
"par" => {
|
||||
flush(&mut line, level, indent, &mut out);
|
||||
}
|
||||
"pard" => {
|
||||
level = None;
|
||||
indent = 0;
|
||||
}
|
||||
"outlinelevel" => level = arg.map(|a| a.clamp(0, 9) as u32),
|
||||
"li" => indent = arg.unwrap_or(0),
|
||||
"tab" | "line" => line.push(' '),
|
||||
"uc" => st.uc = arg.unwrap_or(1).clamp(0, 8) as usize,
|
||||
"u" => {
|
||||
let v = arg.unwrap_or(0);
|
||||
// RTF writes UTF-16 units as signed 16-bit values.
|
||||
push_utf16(&mut line, (v & 0xffff) as u32);
|
||||
pending_skip = 0;
|
||||
// Skip the fallback characters.
|
||||
let mut k = st.uc;
|
||||
while k > 0 && i < b.len() {
|
||||
if b[i] == b'\\' && b.get(i + 1) == Some(&b'\'') {
|
||||
i = advance(rtf, i, 4);
|
||||
} else if b[i] == b'{' || b[i] == b'}' || b[i] == b'\\' {
|
||||
break;
|
||||
} else {
|
||||
i = advance(rtf, i, 1);
|
||||
}
|
||||
k -= 1;
|
||||
}
|
||||
}
|
||||
"emdash" => line.push('—'),
|
||||
"endash" => line.push('–'),
|
||||
"bullet" => line.push('•'),
|
||||
"lquote" => line.push('‘'),
|
||||
"rquote" => line.push('’'),
|
||||
"ldblquote" => line.push('“'),
|
||||
"rdblquote" => line.push('”'),
|
||||
_ => {}
|
||||
}
|
||||
} else if n == b'\'' {
|
||||
let hex = rtf.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
i = advance(rtf, i, 3);
|
||||
if !st.skip
|
||||
&& let Some(h) = hex
|
||||
&& h != 0
|
||||
{
|
||||
line.push(cp1252(h));
|
||||
}
|
||||
} else {
|
||||
i += 1;
|
||||
if st.skip {
|
||||
continue;
|
||||
}
|
||||
match n {
|
||||
b'\\' | b'{' | b'}' => line.push(n as char),
|
||||
b'~' => line.push('\u{a0}'),
|
||||
b'\n' | b'\r' => flush(&mut line, level, indent, &mut out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
b'\r' | b'\n' => i += 1,
|
||||
_ => {
|
||||
let s = i;
|
||||
while i < b.len() && !matches!(b[i], b'{' | b'}' | b'\\' | b'\r' | b'\n') {
|
||||
i += 1;
|
||||
}
|
||||
if !st.skip
|
||||
&& let Some(t) = rtf.get(s..i)
|
||||
{
|
||||
line.push_str(&t.replace('\u{0}', ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(&mut line, level, indent, &mut out);
|
||||
finish_surrogates(out)
|
||||
}
|
||||
|
||||
/// Push a UTF-16 unit; surrogate halves are parked as private markers (NUL plus four hex digits)
|
||||
/// and joined afterwards. NUL itself is the marker, so a NUL unit is dropped.
|
||||
fn push_utf16(line: &mut String, v: u32) {
|
||||
if v == 0 {
|
||||
return;
|
||||
}
|
||||
match char::from_u32(v) {
|
||||
Some(c) => line.push(c),
|
||||
None => line.push_str(&format!("\u{0}{v:04x}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_surrogates(s: String) -> String {
|
||||
if !s.contains('\u{0}') {
|
||||
return s;
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut rest = s.as_str();
|
||||
while let Some(p) = rest.find('\u{0}') {
|
||||
out.push_str(&rest[..p]);
|
||||
let hi = rest.get(p + 1..p + 5).and_then(|h| u32::from_str_radix(h, 16).ok());
|
||||
let lo = rest.get(p + 6..p + 10).filter(|_| rest.get(p + 5..p + 6) == Some("\u{0}")).and_then(|h| u32::from_str_radix(h, 16).ok());
|
||||
match (hi, lo) {
|
||||
(Some(h), Some(l)) if (0xD800..0xDC00).contains(&h) && (0xDC00..0xE000).contains(&l) => {
|
||||
out.extend(char::from_u32(0x10000 + ((h - 0xD800) << 10) + (l - 0xDC00)));
|
||||
rest = &rest[p + 10..];
|
||||
}
|
||||
_ => {
|
||||
out.push('\u{fffd}');
|
||||
// Skip the NUL and, when four hex digits follow, the digits; otherwise
|
||||
// resume right after the NUL so no following character is lost.
|
||||
let skip = if hi.is_some() { p + 5 } else { p + 1 };
|
||||
rest = rest.get(skip..).unwrap_or("");
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
fn cp1252(b: u8) -> char {
|
||||
const HIGH: [char; 32] = [
|
||||
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8d}', 'Ž', '\u{8f}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—',
|
||||
'˜', '™', 'š', '›', 'œ', '\u{9d}', 'ž', 'Ÿ',
|
||||
];
|
||||
match b {
|
||||
0x80..=0x9f => HIGH[(b - 0x80) as usize],
|
||||
_ => b as char,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
/// Non-ASCII text after a `\\uN` or `\\'hh` escape used to split a character and panic.
|
||||
#[test]
|
||||
fn non_ascii_text_after_escapes_does_not_panic() {
|
||||
let out = rtf_to_outline(r"{\rtf1\pard \u233é x \'€y\par}");
|
||||
assert!(out.contains('x'), "{out:?}");
|
||||
for rtf in [r"{\rtf1 \u233é€ z\par}", r"{\rtf1 \'é\par}", r"{\rtf1 \'4", r"{\rtf1 \u233"] {
|
||||
let _ = rtf_to_outline(rtf);
|
||||
}
|
||||
}
|
||||
|
||||
/// NUL is the surrogate marker, so NULs from `\u0`, `\'00` or raw bytes must not reach `finish_surrogates`.
|
||||
#[test]
|
||||
fn nul_escapes_do_not_drop_text_or_add_a_slide() {
|
||||
assert_eq!(rtf_to_outline(r"{\rtf1 \u0?abcé\par}"), "abcé\n");
|
||||
assert_eq!(rtf_to_outline(r"{\rtf1 a\'00bé\par}"), "abé\n");
|
||||
assert_eq!(rtf_to_outline("{\\rtf1 a\0b\\par}\0\0"), "ab\n");
|
||||
assert_eq!(rtf_to_outline(r"{\rtf1 x\u-70000?y\par}"), "x\u{ee90}y\n");
|
||||
}
|
||||
|
||||
/// A hostile `\li` near `i64::MAX` overflowed the rounding addend in debug builds.
|
||||
#[test]
|
||||
fn huge_left_indent_gives_the_deepest_level() {
|
||||
let out = rtf_to_outline(r"{\rtf1\pard\li9223372036854775807 deep\par\pard\li-9223372036854775808 flat\par}");
|
||||
assert_eq!(out, format!("{}deep\nflat\n", "\t".repeat(9)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_reads_back_as_the_same_outline() {
|
||||
let mut p = deckcraft_model::defaults::blank_presentation(deckcraft_model::defaults::WIDE, Default::default(), false);
|
||||
let outline = "Plan {Q3}\n\tGrow — fast 😀\n\t\tEurope\\Asia\nCafé résumé\n\tDone\n";
|
||||
crate::outline_to_slides(&mut p, outline);
|
||||
let rtf = slides_to_rtf(&p);
|
||||
assert!(sniff_rtf(rtf.as_bytes()));
|
||||
assert!(rtf.contains("\\outlinelevel2") && rtf.contains("Plan \\{Q3\\}"), "{rtf}");
|
||||
assert_eq!(rtf_to_outline(&rtf), crate::slides_to_outline(&p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_word_style_rtf_by_indent_and_hex_escapes() {
|
||||
let rtf = r"{\rtf1\ansi{\fonttbl{\f0 Arial;}}{\*\generator X;}\pard\b Title one\b0\par\pard\li720 Point \'93a\'94\par\pard\li1440 Sub\par}";
|
||||
assert_eq!(rtf_to_outline(rtf), "Title one\n\t\tPoint “a”\n\t\t\t\tSub\n");
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
obj(
|
||||
json!({
|
||||
"path": string("Output path"),
|
||||
"format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "pdf", "gif"], "description": "Output format (default: from the path's extension)"},
|
||||
"format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "rtf", "pdf", "gif"], "description": "Output format (default: from the path's extension)"},
|
||||
"slide": int("Slide index for images"),
|
||||
"all": boolean("All slides (images)"),
|
||||
"scale": num("Pixels per point for images (default 2)"),
|
||||
|
||||
@@ -248,6 +248,46 @@ pub enum TextBuild {
|
||||
ByLetter,
|
||||
}
|
||||
|
||||
/// Most paragraphs a by-paragraph build animates separately; later ones follow the last of them.
|
||||
pub const MAX_BUILD_PARAGRAPHS: usize = 1_000;
|
||||
|
||||
/// What a paragraph does in a by-paragraph build.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum BuildRole {
|
||||
/// Animated on its own: a filled paragraph at the shallowest filled level.
|
||||
Top,
|
||||
/// Follows the top paragraph with this index (deeper, empty or blank paragraphs after it).
|
||||
Member(usize),
|
||||
/// Before the first top paragraph: not animated.
|
||||
Unowned,
|
||||
}
|
||||
|
||||
/// Does the paragraph show any text? A by-paragraph build doesn't animate blank ones on their own.
|
||||
pub fn build_filled(p: &crate::text::Paragraph) -> bool {
|
||||
p.runs.iter().any(|r| !r.text.trim().is_empty())
|
||||
}
|
||||
|
||||
/// The role of every paragraph of `text` in a by-paragraph build, by paragraph index: what the
|
||||
/// timeline plays, and what the PPTX reader and writer map to one file effect per paragraph.
|
||||
pub fn paragraph_build_roles(text: &crate::text::TextBody) -> Vec<BuildRole> {
|
||||
let top = text.paragraphs.iter().filter(|p| build_filled(p)).map(|p| p.level).min().unwrap_or(0);
|
||||
let mut tops = 0;
|
||||
let mut owner: Option<usize> = None;
|
||||
text.paragraphs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
if p.level <= top && build_filled(p) && tops < MAX_BUILD_PARAGRAPHS {
|
||||
tops += 1;
|
||||
owner = Some(i);
|
||||
BuildRole::Top
|
||||
} else {
|
||||
owner.map_or(BuildRole::Unowned, BuildRole::Member)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Animation {
|
||||
@@ -606,3 +646,19 @@ mod option_label_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod build_role_tests {
|
||||
use super::*;
|
||||
use crate::text::TextBody;
|
||||
|
||||
#[test]
|
||||
fn blank_and_deeper_paragraphs_follow_the_top_one_before_them() {
|
||||
let mut t = TextBody::from_text("Before\nLead\n \nSub\nNext");
|
||||
for (p, lvl) in t.paragraphs.iter_mut().zip([1, 0, 0, 1, 0]) {
|
||||
p.level = lvl;
|
||||
}
|
||||
use BuildRole::*;
|
||||
assert_eq!(paragraph_build_roles(&t), vec![Unowned, Top, Member(1), Member(1), Top]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -732,6 +732,12 @@ impl Chart {
|
||||
crate::numfmt::format(code.map_or("General", String::as_str), v, self.lang.as_deref())
|
||||
}
|
||||
|
||||
/// Does series `s` show its data labels? The series' own `c:dLbls/c:showVal` wins — an
|
||||
/// explicit `false` hides them even when the chart-wide setting is on (#868).
|
||||
pub fn series_data_labels(&self, s: &Series) -> bool {
|
||||
s.data_labels.unwrap_or(self.data_labels)
|
||||
}
|
||||
|
||||
fn waterfall_value_range(&self) -> (f64, f64) {
|
||||
let mut lo: f64 = 0.0;
|
||||
let mut hi: f64 = 0.0;
|
||||
@@ -819,7 +825,7 @@ pub fn axis_scale(
|
||||
// Office's automatic limits ("How chart axis limits are determined", as Jon Peltier
|
||||
// summarises it): 5 % of the data range past the data, and zero when the values don't
|
||||
// sit within the top sixth of their largest magnitude. A single value is measured from zero.
|
||||
let flat = hi - lo < 1e-12;
|
||||
let flat = hi - lo <= 1e-12 * lo.abs().max(hi.abs());
|
||||
let pad = if !headroom {
|
||||
0.0
|
||||
} else if flat {
|
||||
@@ -831,12 +837,15 @@ pub fn axis_scale(
|
||||
let auto_hi = if hi <= 0.0 && (flat || hi > lo * 5.0 / 6.0) { 0.0 } else { hi + pad };
|
||||
let mut lo = fmin.unwrap_or(auto_lo);
|
||||
let mut hi = fmax.unwrap_or(auto_hi);
|
||||
if hi - lo < 1e-12 {
|
||||
let mag = lo.abs().max(hi.abs());
|
||||
// Relative to the data's magnitude, so tiny values don't count as flat; all-zero falls back to 1.
|
||||
let unit = if mag == 0.0 { 1.0 } else { mag };
|
||||
if hi - lo <= 1e-12 * mag {
|
||||
// A fixed limit on the far side of the data: the automatic end gives way.
|
||||
match (fmin, fmax) {
|
||||
(None, Some(_)) => lo = if hi > 0.0 { 0.0 } else { hi - hi.abs().max(1.0) },
|
||||
(None, Some(_)) => lo = if hi > 0.0 { 0.0 } else { hi - hi.abs().max(unit) },
|
||||
(Some(_), None) if lo < 0.0 => hi = 0.0,
|
||||
_ => hi = lo + lo.abs().max(1.0),
|
||||
_ => hi = lo + unit,
|
||||
}
|
||||
}
|
||||
let n = max_intervals.clamp(1, 50) as f64;
|
||||
@@ -844,6 +853,10 @@ pub fn axis_scale(
|
||||
Some(m) => m,
|
||||
None => {
|
||||
let mut step = 10f64.powf(((hi - lo) / n).log10().floor());
|
||||
// Subnormal spans underflow to a zero (or non-finite) step: fall back to the unit.
|
||||
if !(step > 0.0 && step.is_finite()) {
|
||||
step = unit;
|
||||
}
|
||||
let mut k = 0;
|
||||
// 1, 2, 5, 10, 20… × the magnitude until the intervals fit.
|
||||
while ((hi / step).ceil() - (lo / step).floor()) > n && k < 30 {
|
||||
@@ -861,7 +874,9 @@ pub fn axis_scale(
|
||||
/// "Nice" axis scale: (min, max, major step) covering [lo, hi].
|
||||
pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) {
|
||||
let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi), lo.max(hi)) } else { (0.0, 1.0) };
|
||||
let span = if hi - lo < 1e-12 { hi.abs().max(1.0) } else { hi - lo };
|
||||
let data_mag = lo.abs().max(hi.abs());
|
||||
let span = if hi - lo <= 1e-12 * data_mag { data_mag } else { hi - lo };
|
||||
let span = if span == 0.0 { 1.0 } else { span };
|
||||
let raw = span / 5.0;
|
||||
let mag = 10f64.powf(raw.log10().floor());
|
||||
let norm = raw / mag;
|
||||
@@ -876,9 +891,11 @@ pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) {
|
||||
} else {
|
||||
10.0
|
||||
} * mag;
|
||||
// Subnormal spans underflow to a zero or NaN step: use the span itself.
|
||||
let step = if step > 0.0 && step.is_finite() { step } else { span };
|
||||
let min = (lo / step).floor() * step;
|
||||
let max = (hi / step).ceil() * step;
|
||||
let max = if (max - min).abs() < 1e-12 { min + step } else { max };
|
||||
let max = if (max - min).abs() <= 1e-12 * data_mag { min + step } else { max };
|
||||
(min, max, step)
|
||||
}
|
||||
|
||||
@@ -886,6 +903,25 @@ pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tiny_data_keeps_its_own_scale() {
|
||||
let (min, max, step) = axis_scale(1e-13, 5e-13, None, None, None, 8, true);
|
||||
assert!(min == 0.0 && (5e-13..1e-11).contains(&max), "{min} {max} {step}");
|
||||
assert!(step > 0.0 && step < 1e-11);
|
||||
let (min, max, step) = nice_scale(1e-13, 5e-13);
|
||||
assert!(min <= 1e-13 && (5e-13..1e-11).contains(&max) && step < 1e-11, "{min} {max} {step}");
|
||||
assert_eq!(axis_scale(0.0, 0.0, None, None, None, 8, true).1, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subnormal_data_gives_a_finite_scale() {
|
||||
let (min, max, step) = axis_scale(0.0, 5e-324, None, None, None, 8, true);
|
||||
assert!(min.is_finite() && max.is_finite() && step.is_finite(), "{min} {max} {step}");
|
||||
assert!(step > 0.0 && max >= min, "{min} {max} {step}");
|
||||
let (min, max, step) = nice_scale(0.0, f64::from_bits(1));
|
||||
assert!(min.is_finite() && max.is_finite() && step > 0.0 && step.is_finite() && max > min, "{min} {max} {step}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_axes_run_over_whole_powers() {
|
||||
let mut c = Chart::sample(ChartType::Line);
|
||||
@@ -897,6 +933,20 @@ mod tests {
|
||||
assert_eq!(c.log_axis_scale(2), Some((0.0, 6.0, 3.0)));
|
||||
}
|
||||
|
||||
/// #868: a series' own label setting wins; an unset series takes the chart-wide one.
|
||||
#[test]
|
||||
fn series_data_labels_override_the_chart_wide_setting() {
|
||||
let mut c = Chart::sample(ChartType::Column);
|
||||
c.data_labels = false;
|
||||
let mut s = c.series[0].clone();
|
||||
assert!(!c.series_data_labels(&s), "unset series follows the chart");
|
||||
s.data_labels = Some(true);
|
||||
assert!(c.series_data_labels(&s), "a series that asks for labels gets them");
|
||||
c.data_labels = true;
|
||||
s.data_labels = Some(false);
|
||||
assert!(!c.series_data_labels(&s), "an explicit series false hides them anyway");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_bar_ranges() {
|
||||
let vals = [Some(3.0), Some(5.0), Some(2.0), Some(4.0)];
|
||||
|
||||
@@ -5,7 +5,7 @@ use deckcraft_model::style::Fill;
|
||||
|
||||
use crate::xml::El;
|
||||
|
||||
/// Fills of chart parts never reference images we keep; read colour fills only.
|
||||
/// Fills of chart parts never reference images we keep; read colour and pattern fills only.
|
||||
fn simple_fill(sp: Option<&El>) -> Option<Fill> {
|
||||
let sp = sp?;
|
||||
for e in sp.elements() {
|
||||
@@ -13,6 +13,7 @@ fn simple_fill(sp: Option<&El>) -> Option<Fill> {
|
||||
"noFill" => return Some(Fill::None),
|
||||
"solidFill" => return super::dml::color(e).map(|color| Fill::Solid { color }),
|
||||
"gradFill" => return Some(Fill::Gradient(super::dml::gradient(e))),
|
||||
"pattFill" => return Some(Fill::Pattern(super::dml::pattern(e))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -211,7 +212,11 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
|
||||
if let Some(v) = g.child_val("holeSize").and_then(|v| v.parse::<f64>().ok()) {
|
||||
c.hole_size = (v / 100.0).clamp(0.0, 0.9);
|
||||
}
|
||||
if g.path(&["dLbls", "showVal"]).and_then(|v| v.bool("val")) == Some(true) {
|
||||
// `<c:showVal/>` without a val attribute means true, as in the schema's default.
|
||||
// The setting belongs to this plot group: a group that asks for no labels keeps them
|
||||
// off, even when another group of a combo chart asks for them (#868).
|
||||
let group_labels = g.path(&["dLbls", "showVal"]).map(|v| v.bool("val").unwrap_or(true));
|
||||
if group_labels == Some(true) {
|
||||
c.data_labels = true;
|
||||
}
|
||||
kinds.push(kind);
|
||||
@@ -228,8 +233,12 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
|
||||
secondary_axis: secondary,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).and_then(|v| v.bool("val")) {
|
||||
// `<c:showVal/>` without a val attribute means true, as in the schema's default.
|
||||
if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).map(|v| v.bool("val").unwrap_or(true)) {
|
||||
ser.data_labels = Some(d);
|
||||
} else if let Some(false) = group_labels {
|
||||
// The group asks for no labels and the series does not say otherwise.
|
||||
ser.data_labels = Some(false);
|
||||
}
|
||||
for t in s.children_named("trendline").take(16) {
|
||||
if let Some(t) = trendline(t) {
|
||||
@@ -576,6 +585,29 @@ mod tests {
|
||||
assert!(!read_chart_xml(&crate::write::chart::chart_xml(&c).0).unwrap().minor_gridlines);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn series_pattern_fills_are_read_and_written_back() {
|
||||
// A pattern-filled series and an empty one with an outline, as PowerPoint writes them.
|
||||
let xml = br#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/><c:ser><c:idx val="0"/><c:spPr><a:pattFill prst="openDmnd"><a:fgClr><a:schemeClr val="accent4"/></a:fgClr><a:bgClr><a:schemeClr val="bg1"/></a:bgClr></a:pattFill></c:spPr><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>5</c:v></c:pt></c:numLit></c:val></c:ser><c:ser><c:idx val="1"/><c:spPr><a:noFill/><a:ln w="19050"><a:solidFill><a:schemeClr val="accent4"/></a:solidFill></a:ln></c:spPr><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>3</c:v></c:pt></c:numLit></c:val></c:ser></c:barChart></c:plotArea></c:chart></c:chartSpace>"#;
|
||||
let c = read_chart_xml(xml).unwrap();
|
||||
let accent4 = deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent4);
|
||||
let pattern = deckcraft_model::style::PatternFill {
|
||||
preset: "openDmnd".into(),
|
||||
fg: accent4.clone(),
|
||||
bg: deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1),
|
||||
};
|
||||
assert_eq!(c.series[0].fill, Some(Fill::Pattern(pattern.clone())));
|
||||
assert_eq!(c.series[1].fill, Some(Fill::None));
|
||||
assert_eq!(c.series[1].line.as_ref().and_then(|l| l.fill.clone()), Some(Fill::solid(accent4)));
|
||||
// Written out from the model (an edited chart), the pattern survives.
|
||||
let mut edited = c.clone();
|
||||
edited.raw = None;
|
||||
edited.title = Some("Edited".into());
|
||||
let back = read_chart_xml(&crate::write::chart::chart_xml(&edited).0).unwrap();
|
||||
assert_eq!(back.series[0].fill, Some(Fill::Pattern(pattern)));
|
||||
assert_eq!(back.series[1].fill, Some(Fill::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_base_is_read() {
|
||||
let xml = br#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:lineChart><c:axId val="1"/><c:axId val="2"/></c:lineChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/><c:scaling><c:logBase val="10"/><c:orientation val="minMax"/></c:scaling></c:valAx></c:plotArea></c:chart></c:chartSpace>"#;
|
||||
|
||||
@@ -86,16 +86,21 @@ pub fn fill_el(imp: &mut Imp, part: &Part, f: &El) -> Option<Fill> {
|
||||
Some(p) => Fill::Picture(p),
|
||||
None => Fill::None,
|
||||
},
|
||||
"pattFill" => Fill::Pattern(PatternFill {
|
||||
preset: f.attr("prst").unwrap_or("pct5").to_string(),
|
||||
fg: f.child("fgClr").and_then(color).unwrap_or(ColorRef::rgb(Rgba::BLACK)),
|
||||
bg: f.child("bgClr").and_then(color).unwrap_or(ColorRef::rgb(Rgba::WHITE)),
|
||||
}),
|
||||
"pattFill" => Fill::Pattern(pattern(f)),
|
||||
"grpFill" => Fill::Group,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// An `a:pattFill`: its preset (5 % dots when unset), foreground (black) and background (white).
|
||||
pub fn pattern(f: &El) -> PatternFill {
|
||||
PatternFill {
|
||||
preset: f.attr("prst").unwrap_or("pct5").to_string(),
|
||||
fg: f.child("fgClr").and_then(color).unwrap_or(ColorRef::rgb(Rgba::BLACK)),
|
||||
bg: f.child("bgClr").and_then(color).unwrap_or(ColorRef::rgb(Rgba::WHITE)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gradient(f: &El) -> Gradient {
|
||||
let mut stops: Vec<GradientStop> = f
|
||||
.child("gsLst")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Slide transitions (`p:transition`) and animations (`p:timing`).
|
||||
|
||||
use deckcraft_model::Shape;
|
||||
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, TextBuild, Transition};
|
||||
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, BuildRole, TextBuild, Transition, build_filled, paragraph_build_roles};
|
||||
use deckcraft_model::{Shape, TextBody, find_shape};
|
||||
|
||||
use super::shapes::IdCtx;
|
||||
use super::{Imp, Part, dml};
|
||||
@@ -95,7 +95,7 @@ fn max_dur(e: &El, depth: usize) -> Option<u32> {
|
||||
}
|
||||
|
||||
/// Flatten the timing tree into the Animation Pane list.
|
||||
pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, _shapes: &[Shape]) -> Vec<Animation> {
|
||||
pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes: &[Shape]) -> Vec<Animation> {
|
||||
let _ = (imp, part);
|
||||
let Some(tn) = timing.child("tnLst") else { return vec![] };
|
||||
let mut found = vec![];
|
||||
@@ -110,7 +110,7 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, _shapes:
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut out: Vec<(Animation, u32, String)> = vec![];
|
||||
let mut out: Vec<(Animation, u32, String, bool)> = vec![];
|
||||
for f in found {
|
||||
let c = f.ctn;
|
||||
let class = AnimClass::from_xml(c.attr("presetClass").unwrap_or("entr"));
|
||||
@@ -172,15 +172,56 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, _shapes:
|
||||
a.color = dml::color(clr);
|
||||
}
|
||||
let grp = c.attr("grpId").unwrap_or("0").to_string();
|
||||
// Collapse a paragraph-by-paragraph build into one animation.
|
||||
if paragraph.is_some() && by_para.iter().any(|(s, g)| *s == file_id && *g == grp) {
|
||||
if out.last().is_some_and(|(l, lf, lg)| *lf == file_id && *lg == grp && l.text_build == TextBuild::ByParagraph) {
|
||||
continue;
|
||||
}
|
||||
a.text_build = TextBuild::ByParagraph;
|
||||
a.paragraph = None;
|
||||
}
|
||||
out.push((a, file_id, grp));
|
||||
let build = paragraph.is_some() && by_para.iter().any(|(s, g)| *s == file_id && *g == grp);
|
||||
out.push((a, file_id, grp, build));
|
||||
}
|
||||
out.into_iter().map(|(a, _, _)| a).collect()
|
||||
// A paragraph build's effects, one per paragraph, become one by-paragraph animation only when
|
||||
// the timeline plays that back exactly: PowerPoint lets each paragraph keep its own start and
|
||||
// effect, and other shapes' effects can sit between them (#844).
|
||||
let same_build =
|
||||
|x: &(Animation, u32, String, bool), y: &(Animation, u32, String, bool)| x.3 && y.3 && x.1 == y.1 && x.2 == y.2 && x.0.trigger == y.0.trigger;
|
||||
let mut anims = Vec::with_capacity(out.len());
|
||||
for run in out.chunk_by(same_build) {
|
||||
let effects: Vec<&Animation> = run.iter().map(|(a, ..)| a).collect();
|
||||
let build = run.first().is_some_and(|r| r.3);
|
||||
let text = effects.first().and_then(|a| find_shape(shapes, a.shape)).and_then(|s| s.text.as_ref());
|
||||
match (effects.first(), text) {
|
||||
(Some(first), Some(text)) if build && plays_as_build(&effects, text) => {
|
||||
anims.push(Animation { text_build: TextBuild::ByParagraph, paragraph: None, ..(*first).clone() });
|
||||
}
|
||||
_ => anims.extend(effects.into_iter().cloned()),
|
||||
}
|
||||
}
|
||||
anims
|
||||
}
|
||||
|
||||
/// Are `effects` (paragraph effects of one shape, in file order) what a by-paragraph build of the
|
||||
/// first one plays: one effect per paragraph the build animates, in paragraph order, later top
|
||||
/// paragraphs on click (after previous when the build doesn't start on click), deeper ones with
|
||||
/// their parent, all with the same settings?
|
||||
fn plays_as_build(effects: &[&Animation], text: &TextBody) -> bool {
|
||||
let roles = paragraph_build_roles(text);
|
||||
let expected = roles.iter().enumerate().filter(|(i, r)| match r {
|
||||
BuildRole::Top => true,
|
||||
BuildRole::Member(_) => text.paragraphs.get(*i).is_some_and(build_filled),
|
||||
BuildRole::Unowned => false,
|
||||
});
|
||||
let Some(first) = effects.first() else { return false };
|
||||
let settings = |a: &Animation| Animation { start: AnimStart::OnClick, paragraph: None, ..a.clone() };
|
||||
let base = settings(first);
|
||||
let mut matched = 0;
|
||||
for (k, (i, role)) in expected.enumerate() {
|
||||
let Some(a) = effects.get(k) else { return false };
|
||||
let start_ok = match role {
|
||||
BuildRole::Top if k == 0 => true,
|
||||
BuildRole::Top if first.start == AnimStart::OnClick => a.start == AnimStart::OnClick,
|
||||
BuildRole::Top => a.start == AnimStart::AfterPrevious,
|
||||
_ => a.start == AnimStart::WithPrevious,
|
||||
};
|
||||
if a.paragraph != u32::try_from(i).ok() || !start_ok || settings(a) != base {
|
||||
return false;
|
||||
}
|
||||
matched = k + 1;
|
||||
}
|
||||
matched == effects.len()
|
||||
}
|
||||
|
||||
+119
-12
@@ -30,7 +30,17 @@ fn simple_fill(w: &mut W, f: &Fill) {
|
||||
match f {
|
||||
Fill::None => w.empty0("a:noFill"),
|
||||
Fill::Solid { color } => super::dml::solid(w, color),
|
||||
Fill::Gradient(_) | Fill::Picture(_) | Fill::Pattern(_) | Fill::Group | Fill::Background => {
|
||||
Fill::Pattern(p) => {
|
||||
w.open("a:pattFill", A::new().a("prst", if p.preset.is_empty() { "pct5" } else { &p.preset }));
|
||||
w.open0("a:fgClr");
|
||||
super::dml::color(w, &p.fg);
|
||||
w.close("a:fgClr");
|
||||
w.open0("a:bgClr");
|
||||
super::dml::color(w, &p.bg);
|
||||
w.close("a:bgClr");
|
||||
w.close("a:pattFill");
|
||||
}
|
||||
Fill::Gradient(_) | Fill::Picture(_) | Fill::Group | Fill::Background => {
|
||||
if let Fill::Gradient(g) = f
|
||||
&& let Some(s) = g.stops.first()
|
||||
{
|
||||
@@ -58,14 +68,54 @@ fn sp_pr(w: &mut W, fill: Option<&Fill>, line: Option<&deckcraft_model::Line>) {
|
||||
w.close("c:spPr");
|
||||
}
|
||||
|
||||
fn rich(w: &mut W, text: &str) {
|
||||
/// `a:defRPr` carrying the chart's text colour, where PowerPoint keeps it: the paragraph
|
||||
/// defaults of a title's rich text and of every `c:txPr`.
|
||||
fn def_rpr(w: &mut W, color: Option<&deckcraft_model::ColorRef>) {
|
||||
match color {
|
||||
Some(c) => {
|
||||
w.open0("a:defRPr");
|
||||
super::dml::solid(w, c);
|
||||
w.close("a:defRPr");
|
||||
}
|
||||
None => w.empty0("a:defRPr"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `c:txPr` with the chart's text colour (`Chart::text_color`) as the default run properties, on
|
||||
/// the axes, the legend, the data labels and the chart itself, as PowerPoint writes it. Nothing
|
||||
/// without a colour: the theme's default then applies on reading.
|
||||
fn tx_pr(w: &mut W, c: &Chart) {
|
||||
let Some(color) = &c.text_color else { return };
|
||||
w.open0("c:txPr");
|
||||
w.empty0("a:bodyPr");
|
||||
w.empty0("a:lstStyle");
|
||||
w.open0("a:p");
|
||||
w.open0("a:pPr");
|
||||
def_rpr(w, Some(color));
|
||||
w.close("a:pPr");
|
||||
w.empty("a:endParaRPr", A::new().a("lang", "en-US"));
|
||||
w.close("a:p");
|
||||
w.close("c:txPr");
|
||||
}
|
||||
|
||||
fn rich(w: &mut W, text: &str, color: Option<&deckcraft_model::ColorRef>) {
|
||||
w.open0("c:tx");
|
||||
w.open0("c:rich");
|
||||
w.empty0("a:bodyPr");
|
||||
w.empty0("a:lstStyle");
|
||||
for line in text.split('\n') {
|
||||
w.open0("a:p");
|
||||
if color.is_some() {
|
||||
w.open0("a:pPr");
|
||||
def_rpr(w, color);
|
||||
w.close("a:pPr");
|
||||
}
|
||||
w.open0("a:r");
|
||||
if let Some(c) = color {
|
||||
w.open("a:rPr", A::new().a("lang", "en-US"));
|
||||
super::dml::solid(w, c);
|
||||
w.close("a:rPr");
|
||||
}
|
||||
w.elt("a:t", line);
|
||||
w.close("a:r");
|
||||
w.close("a:p");
|
||||
@@ -74,9 +124,10 @@ fn rich(w: &mut W, text: &str) {
|
||||
w.close("c:tx");
|
||||
}
|
||||
|
||||
fn title(w: &mut W, text: &str) {
|
||||
/// A title (the chart's or an axis's) in the chart's text colour.
|
||||
fn title(w: &mut W, text: &str, c: &Chart) {
|
||||
w.open0("c:title");
|
||||
rich(w, text);
|
||||
rich(w, text, c.text_color.as_ref());
|
||||
w.val("c:overlay", 0);
|
||||
w.close("c:title");
|
||||
}
|
||||
@@ -217,7 +268,7 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group)
|
||||
}
|
||||
}
|
||||
if let Some(d) = s.data_labels {
|
||||
data_labels(w, d);
|
||||
data_labels(w, c, d);
|
||||
}
|
||||
// After labels, before the category and value caches (ECMA-376 series content order).
|
||||
if matches!(g, Group::Bar | Group::Line | Group::Area | Group::Scatter) {
|
||||
@@ -325,8 +376,9 @@ fn trendline(w: &mut W, t: &Trendline) {
|
||||
w.close("c:trendline");
|
||||
}
|
||||
|
||||
fn data_labels(w: &mut W, show: bool) {
|
||||
fn data_labels(w: &mut W, c: &Chart, show: bool) {
|
||||
w.open0("c:dLbls");
|
||||
tx_pr(w, c);
|
||||
for (n, v) in [
|
||||
("c:showLegendKey", false),
|
||||
("c:showVal", show),
|
||||
@@ -355,12 +407,13 @@ fn axes(w: &mut W, c: &Chart, g: Group) {
|
||||
w.val("c:delete", 0);
|
||||
w.val("c:axPos", "b");
|
||||
if let Some(t) = &c.axis_titles.0 {
|
||||
title(w, t);
|
||||
title(w, t, c);
|
||||
}
|
||||
w.empty("c:numFmt", A::new().a("formatCode", "General").a("sourceLinked", 1));
|
||||
w.val("c:majorTickMark", "out");
|
||||
w.val("c:minorTickMark", "none");
|
||||
w.val("c:tickLblPos", "nextTo");
|
||||
tx_pr(w, c);
|
||||
w.val("c:crossAx", AX_VAL);
|
||||
w.val("c:crosses", "autoZero");
|
||||
w.val("c:crossBetween", "midCat");
|
||||
@@ -374,12 +427,13 @@ fn axes(w: &mut W, c: &Chart, g: Group) {
|
||||
w.val("c:delete", 0);
|
||||
w.val("c:axPos", cat_pos);
|
||||
if let Some(t) = &c.axis_titles.0 {
|
||||
title(w, t);
|
||||
title(w, t, c);
|
||||
}
|
||||
w.empty("c:numFmt", A::new().a("formatCode", "General").a("sourceLinked", 1));
|
||||
w.val("c:majorTickMark", "none");
|
||||
w.val("c:minorTickMark", "none");
|
||||
w.val("c:tickLblPos", "nextTo");
|
||||
tx_pr(w, c);
|
||||
w.val("c:crossAx", AX_VAL);
|
||||
w.val("c:crosses", "autoZero");
|
||||
w.val("c:auto", 1);
|
||||
@@ -411,7 +465,7 @@ fn axes(w: &mut W, c: &Chart, g: Group) {
|
||||
w.empty0("c:minorGridlines");
|
||||
}
|
||||
if let Some(t) = &c.axis_titles.1 {
|
||||
title(w, t);
|
||||
title(w, t, c);
|
||||
}
|
||||
match c.y_format.as_deref().filter(|f| !f.is_empty()) {
|
||||
Some(f) => w.empty("c:numFmt", A::new().a("formatCode", f).a("sourceLinked", 0)),
|
||||
@@ -420,6 +474,7 @@ fn axes(w: &mut W, c: &Chart, g: Group) {
|
||||
w.val("c:majorTickMark", "none");
|
||||
w.val("c:minorTickMark", "none");
|
||||
w.val("c:tickLblPos", "nextTo");
|
||||
tx_pr(w, c);
|
||||
w.val("c:crossAx", AX_CAT);
|
||||
w.val("c:crosses", "autoZero");
|
||||
w.val("c:crossBetween", if g == Group::Area || g == Group::Scatter { "midCat" } else { "between" });
|
||||
@@ -468,7 +523,7 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)])
|
||||
series(w, c, s, *i, kind, g);
|
||||
}
|
||||
if c.data_labels {
|
||||
data_labels(w, true);
|
||||
data_labels(w, c, true);
|
||||
}
|
||||
match g {
|
||||
Group::Bar => {
|
||||
@@ -576,7 +631,7 @@ pub fn chart_xml(c: &Chart) -> (Vec<u8>, bool) {
|
||||
w.open0("c:chart");
|
||||
match &c.title {
|
||||
Some(t) => {
|
||||
title(&mut w, t);
|
||||
title(&mut w, t, c);
|
||||
w.val("c:autoTitleDeleted", 0);
|
||||
}
|
||||
None => w.val("c:autoTitleDeleted", 1),
|
||||
@@ -654,6 +709,7 @@ pub fn chart_xml(c: &Chart) -> (Vec<u8>, bool) {
|
||||
w.open0("c:legend");
|
||||
w.val("c:legendPos", p);
|
||||
w.val("c:overlay", u8::from(c.legend_overlay));
|
||||
tx_pr(&mut w, c);
|
||||
w.close("c:legend");
|
||||
}
|
||||
w.val("c:plotVisOnly", 1);
|
||||
@@ -662,6 +718,7 @@ pub fn chart_xml(c: &Chart) -> (Vec<u8>, bool) {
|
||||
if let Some(f) = &c.chart_fill {
|
||||
sp_pr(&mut w, Some(f), None);
|
||||
}
|
||||
tx_pr(&mut w, c);
|
||||
w.close("c:chartSpace");
|
||||
(w.finish(), false)
|
||||
}
|
||||
@@ -866,7 +923,7 @@ pub fn build_chartex(c: &Chart) -> Vec<u8> {
|
||||
for (i, s) in c.series.iter().enumerate().take(if one { 1 } else { usize::MAX }) {
|
||||
w.open("cx:series", A::new().a("layoutId", layout).a("uniqueId", format!("{{DC00000{i}-0000-4000-8000-000000000000}}")));
|
||||
cx_text(&mut w, &s.name);
|
||||
if s.data_labels.unwrap_or(c.data_labels) {
|
||||
if c.series_data_labels(s) {
|
||||
w.open("cx:dataLabels", A::new().a("pos", if c.kind == ChartType::Funnel { "ctr" } else { "outEnd" }));
|
||||
w.empty("cx:visibility", A::new().a("seriesName", "0").a("categoryName", "0").a("value", "1"));
|
||||
w.close("cx:dataLabels");
|
||||
@@ -980,8 +1037,58 @@ mod region_map_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use deckcraft_color::{ColorTransform, Rgba};
|
||||
use deckcraft_model::ColorRef;
|
||||
use deckcraft_model::chart::{Chart, ChartType};
|
||||
|
||||
/// The chart's text colour is written where PowerPoint keeps it (`c:txPr` on the axes, legend,
|
||||
/// data labels and chart, the title's run defaults) and read back as the same colour.
|
||||
#[test]
|
||||
fn chart_text_colour_round_trips() {
|
||||
let mut c = Chart::sample(ChartType::Column);
|
||||
c.title = Some("Revenue".into());
|
||||
c.legend = Some("b".into());
|
||||
c.data_labels = true;
|
||||
c.axis_titles = (Some("Quarter".into()), Some("EUR".into()));
|
||||
let tinted = ColorRef::scheme(deckcraft_model::SchemeSlot::Accent2).with(ColorTransform::LumMod(75000));
|
||||
c.text_color = Some(tinted.clone());
|
||||
let xml = String::from_utf8(super::chart_xml(&c).0).unwrap();
|
||||
// Axes (2), legend, data labels and the chart root; a title keeps it in its rich text.
|
||||
assert!(xml.matches("<c:txPr>").count() >= 5, "{xml}");
|
||||
assert!(xml.contains(r#"<a:schemeClr val="accent2"><a:lumMod val="75000"/></a:schemeClr>"#), "{xml}");
|
||||
assert!(xml.contains("<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr><a:solidFill>"), "{xml}");
|
||||
assert_eq!(crate::read::chart::read_chart_xml(xml.as_bytes()).unwrap().text_color, Some(tinted));
|
||||
// An RGB colour and no colour at all round-trip too.
|
||||
c.text_color = Some(ColorRef::rgb(Rgba::rgb(255, 255, 255)));
|
||||
let xml = super::chart_xml(&c).0;
|
||||
assert_eq!(crate::read::chart::read_chart_xml(&xml).unwrap().text_color, c.text_color);
|
||||
c.text_color = None;
|
||||
let xml = String::from_utf8(super::chart_xml(&c).0).unwrap();
|
||||
assert!(!xml.contains("<c:txPr>"), "{xml}");
|
||||
assert_eq!(crate::read::chart::read_chart_xml(xml.as_bytes()).unwrap().text_color, None);
|
||||
}
|
||||
|
||||
/// An imported chart's `c:txPr` colour survives an edit that rebuilds the part from the model
|
||||
/// (the colour used to be read but never written, so a save after any edit lost it).
|
||||
#[test]
|
||||
fn imported_text_colour_survives_an_edit_and_a_save() {
|
||||
let xml = r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/><c:ser><c:idx val="0"/><c:val><c:numRef><c:numCache><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numCache></c:numRef></c:val></c:ser><c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/><c:txPr><a:bodyPr/><a:p><a:pPr><a:defRPr><a:solidFill><a:schemeClr val="tx1"><a:lumMod val="65000"/><a:lumOff val="35000"/></a:schemeClr></a:solidFill></a:defRPr></a:pPr></a:p></c:txPr><c:crossAx val="2"/></c:catAx><c:valAx><c:axId val="2"/><c:crossAx val="1"/></c:valAx></c:plotArea></c:chart></c:chartSpace>"#;
|
||||
let c = crate::read::chart::read_chart_xml(xml.as_bytes()).unwrap();
|
||||
let grey = ColorRef::scheme(deckcraft_model::SchemeSlot::Tx1).with(ColorTransform::LumMod(65000)).with(ColorTransform::LumOff(35000));
|
||||
assert_eq!(c.text_color, Some(grey.clone()));
|
||||
// Unchanged: the part is written back as read.
|
||||
assert!(super::chart_xml(&c).1);
|
||||
// Edited (a title; the editor drops `raw` on any edit): rebuilt with the colour.
|
||||
let mut e = c.clone();
|
||||
e.title = Some("Units".into());
|
||||
e.raw = None;
|
||||
let (out, kept) = super::chart_xml(&e);
|
||||
assert!(!kept);
|
||||
let back = crate::read::chart::read_chart_xml(&out).unwrap();
|
||||
assert_eq!(back.text_color, Some(grey));
|
||||
assert_eq!(back.title.as_deref(), Some("Units"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversed_category_axis_round_trips() {
|
||||
let mut c = Chart::sample(ChartType::Bar);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Transitions and the animation timing tree.
|
||||
|
||||
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, TextBuild, Transition};
|
||||
use deckcraft_model::{Shape, ShapeKind, find_shape};
|
||||
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, BuildRole, TextBuild, Transition, build_filled, paragraph_build_roles};
|
||||
use deckcraft_model::{Shape, ShapeId, ShapeKind, find_shape};
|
||||
|
||||
use super::shapes::IdMap;
|
||||
use super::{Exp, Out};
|
||||
@@ -565,53 +565,68 @@ fn click_pars(w: &mut W, x: &mut Exp, ids: &mut Ids, clicks: &Groups, interactiv
|
||||
}
|
||||
}
|
||||
|
||||
fn para_count(s: &Shape) -> u32 {
|
||||
s.text.as_ref().map(|t| t.paragraphs.iter().filter(|p| !p.is_empty()).count()).unwrap_or(0).min(10_000) as u32
|
||||
}
|
||||
|
||||
/// Write `p:timing` for a slide's animations.
|
||||
pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idmap: &IdMap) {
|
||||
let mut grp_count: std::collections::HashMap<u32, u32> = Default::default();
|
||||
let mut main: Vec<Eff> = vec![];
|
||||
let mut triggered: Vec<(u32, Vec<Eff>)> = vec![];
|
||||
let mut bld: Vec<(u32, u32, bool)> = vec![];
|
||||
// The last paragraph effect written: (shape, class, trigger, grpId).
|
||||
let mut prev_para: Option<(ShapeId, AnimClass, Option<ShapeId>, u32)> = None;
|
||||
for a in anims.iter().take(10_000) {
|
||||
let Some(spid) = idmap.get(a.shape) else {
|
||||
log::warn!("pptx: animation on missing shape {} skipped", a.shape);
|
||||
continue;
|
||||
};
|
||||
let Some(shape) = find_shape(shapes, a.shape) else { continue };
|
||||
let grp = {
|
||||
let c = grp_count.entry(spid).or_insert(0);
|
||||
let g = *c;
|
||||
*c += 1;
|
||||
g
|
||||
};
|
||||
let text_sp = matches!(shape.kind, ShapeKind::Shape) && shape.text.is_some();
|
||||
let is_text_sp = matches!(shape.kind, ShapeKind::Shape) && shape.text.as_ref().is_some_and(|t| !t.is_empty());
|
||||
let by_para = a.text_build == TextBuild::ByParagraph && is_text_sp && para_count(shape) > 0;
|
||||
let text = shape.text.as_ref().filter(|_| a.text_build == TextBuild::ByParagraph && is_text_sp);
|
||||
let roles = text.map(paragraph_build_roles).unwrap_or_default();
|
||||
let by_para = roles.contains(&BuildRole::Top);
|
||||
// PowerPoint keeps a shape's consecutive paragraph effects in one build (`grpId`) marked
|
||||
// `build="p"`; the reader turns them back into a by-paragraph build only if that plays them
|
||||
// the same way.
|
||||
let para_effect = !by_para && text_sp && a.paragraph.is_some();
|
||||
let joins = para_effect && prev_para.is_some_and(|(s, c, t, _)| s == a.shape && c == a.class && t == a.trigger);
|
||||
let grp = match prev_para {
|
||||
Some((.., g)) if joins => g,
|
||||
_ => {
|
||||
let c = grp_count.entry(spid).or_insert(0);
|
||||
let g = *c;
|
||||
*c += 1;
|
||||
g
|
||||
}
|
||||
};
|
||||
prev_para = para_effect.then_some((a.shape, a.class, a.trigger, grp));
|
||||
let mut effs = vec![];
|
||||
if by_para {
|
||||
// Paragraph indices in the file count empty paragraphs too.
|
||||
let idxs: Vec<u32> = shape
|
||||
.text
|
||||
.as_ref()
|
||||
.map(|t| t.paragraphs.iter().enumerate().filter(|(_, p)| !p.is_empty()).map(|(i, _)| i as u32).collect())
|
||||
.unwrap_or_default();
|
||||
for (k, pi) in idxs.into_iter().enumerate() {
|
||||
let start = if k == 0 {
|
||||
a.start
|
||||
} else if a.start == AnimStart::OnClick {
|
||||
AnimStart::OnClick
|
||||
} else {
|
||||
AnimStart::AfterPrevious
|
||||
// One effect per paragraph the build animates on its own, deeper ones with their
|
||||
// parent; indices in the file count empty paragraphs too.
|
||||
let mut tops = 0;
|
||||
for (i, role) in roles.iter().enumerate() {
|
||||
let start = match role {
|
||||
BuildRole::Top => {
|
||||
tops += 1;
|
||||
if tops == 1 {
|
||||
a.start
|
||||
} else if a.start == AnimStart::OnClick {
|
||||
AnimStart::OnClick
|
||||
} else {
|
||||
AnimStart::AfterPrevious
|
||||
}
|
||||
}
|
||||
BuildRole::Member(_) if text.and_then(|t| t.paragraphs.get(i)).is_some_and(build_filled) => AnimStart::WithPrevious,
|
||||
_ => continue,
|
||||
};
|
||||
let Ok(pi) = u32::try_from(i) else { break };
|
||||
effs.push(Eff { a, spid, start, paragraph: Some(pi), grp });
|
||||
}
|
||||
} else {
|
||||
effs.push(Eff { a, spid, start: a.start, paragraph: a.paragraph, grp });
|
||||
}
|
||||
if matches!(shape.kind, ShapeKind::Shape) && shape.text.is_some() {
|
||||
bld.push((spid, grp, by_para));
|
||||
if text_sp && !joins {
|
||||
bld.push((spid, grp, by_para || para_effect));
|
||||
}
|
||||
match a.trigger.and_then(|t| idmap.get(t)) {
|
||||
Some(t) => match triggered.iter_mut().find(|(s, _)| *s == t) {
|
||||
@@ -698,8 +713,8 @@ pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idm
|
||||
w.close("p:tnLst");
|
||||
if !bld.is_empty() {
|
||||
w.open0("p:bldLst");
|
||||
for (spid, grp, by_para) in bld {
|
||||
w.empty("p:bldP", A::new().a("spid", spid).a("grpId", grp).o("build", by_para.then_some("p")).t("animBg", !by_para));
|
||||
for (spid, grp, build_p) in bld {
|
||||
w.empty("p:bldP", A::new().a("spid", spid).a("grpId", grp).o("build", build_p.then_some("p")).t("animBg", !build_p));
|
||||
}
|
||||
w.close("p:bldLst");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Series-level data labels: `c:dLbls/c:showVal` on a series overrides the chart-wide setting,
|
||||
//! and a bare `<c:showVal/>` (no `val` attribute) means true, as the schema's default (#868).
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::chart::{Chart, ChartType};
|
||||
use deckcraft_model::{LayoutType, Presentation, Shape, ShapeId, ShapeKind, defaults};
|
||||
|
||||
/// A one-slide deck whose bar chart shows no labels anywhere.
|
||||
fn bar_chart_pptx() -> Vec<u8> {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
p.slides.clear();
|
||||
let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default();
|
||||
let mut slide = defaults::new_slide(&mut p, layout).expect("slide");
|
||||
let mut shape =
|
||||
Shape { id: ShapeId(p.alloc_id().expect("id")), name: "Bars".into(), xfrm: Some(Xfrm::new(40.0, 40.0, 400.0, 300.0)), ..Default::default() };
|
||||
let mut chart = Chart::sample(ChartType::Column);
|
||||
chart.data_labels = false;
|
||||
for s in &mut chart.series {
|
||||
s.data_labels = None;
|
||||
}
|
||||
shape.kind = ShapeKind::Chart(Box::new(chart));
|
||||
slide.shapes.push(shape);
|
||||
p.slides.push(Arc::new(slide));
|
||||
deckcraft_pptx::export(&p).expect("export")
|
||||
}
|
||||
|
||||
/// Rewrite the chart part's XML through `f` and re-import the deck.
|
||||
fn reimport(bytes: &[u8], f: impl Fn(&str) -> String) -> Presentation {
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default();
|
||||
for i in 0..zip.len() {
|
||||
let mut entry = zip.by_index(i).expect("entry");
|
||||
let name = entry.name().to_string();
|
||||
let mut data = vec![];
|
||||
entry.read_to_end(&mut data).expect("read");
|
||||
if name.starts_with("ppt/charts/chart") && name.ends_with(".xml") && !name.contains("_rels") {
|
||||
let xml = String::from_utf8(data).expect("chart xml");
|
||||
data = f(&xml).into_bytes();
|
||||
}
|
||||
out.start_file(&name, opts).expect("start");
|
||||
out.write_all(&data).expect("write");
|
||||
}
|
||||
let patched = out.finish().expect("finish").into_inner();
|
||||
deckcraft_pptx::import(&patched).expect("import")
|
||||
}
|
||||
|
||||
fn chart_of(p: &Presentation) -> &Chart {
|
||||
let s = p.slides.first().expect("slide");
|
||||
for sh in &s.shapes {
|
||||
if let ShapeKind::Chart(c) = &sh.kind {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
panic!("no chart in the deck");
|
||||
}
|
||||
|
||||
/// `<c:showVal/>` on the first series: that series shows labels although the chart-wide
|
||||
/// setting is off; the other series stay quiet.
|
||||
#[test]
|
||||
fn bare_series_showval_shows_labels() {
|
||||
let p = reimport(&bar_chart_pptx(), |xml| xml.replacen("<c:cat>", r#"<c:dLbls><c:showVal/></c:dLbls><c:cat>"#, 1));
|
||||
let c = chart_of(&p);
|
||||
assert!(!c.data_labels, "the chart-wide setting stays off");
|
||||
assert_eq!(c.series[0].data_labels, Some(true), "a bare showVal means true");
|
||||
assert!(c.series_data_labels(&c.series[0]), "the first series' labels show");
|
||||
for s in c.series.iter().skip(1) {
|
||||
assert_eq!(s.data_labels, None, "other series keep the chart's setting");
|
||||
assert!(!c.series_data_labels(s), "and stay quiet");
|
||||
}
|
||||
}
|
||||
|
||||
/// `val="0"` on the series hides its labels although the chart-wide setting is on.
|
||||
#[test]
|
||||
fn series_showval_false_hides_labels() {
|
||||
let p = reimport(&bar_chart_pptx(), |xml| {
|
||||
xml.replacen("<c:cat>", r#"<c:dLbls><c:showVal val="0"/></c:dLbls><c:cat>"#, 1).replacen(
|
||||
"<c:gapWidth",
|
||||
r#"<c:dLbls><c:showVal/></c:dLbls><c:gapWidth"#,
|
||||
1,
|
||||
)
|
||||
});
|
||||
let c = chart_of(&p);
|
||||
assert!(c.data_labels, "the bare chart-wide showVal turns labels on");
|
||||
assert_eq!(c.series[0].data_labels, Some(false), "the explicit series false is read");
|
||||
assert!(!c.series_data_labels(&c.series[0]), "and hides that series' labels");
|
||||
assert!(c.series_data_labels(&c.series[1]), "the next series takes the chart-wide on");
|
||||
}
|
||||
|
||||
/// The series' label setting survives a save and load.
|
||||
#[test]
|
||||
fn series_label_settings_round_trip() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
p.slides.clear();
|
||||
let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default();
|
||||
let mut slide = defaults::new_slide(&mut p, layout).expect("slide");
|
||||
let mut chart = Chart::sample(ChartType::Column);
|
||||
chart.data_labels = true;
|
||||
chart.series[0].data_labels = Some(false);
|
||||
chart.series[1].data_labels = Some(true);
|
||||
let mut shape =
|
||||
Shape { id: ShapeId(p.alloc_id().expect("id")), name: "Bars".into(), xfrm: Some(Xfrm::new(40.0, 40.0, 400.0, 300.0)), ..Default::default() };
|
||||
shape.kind = ShapeKind::Chart(Box::new(chart));
|
||||
slide.shapes.push(shape);
|
||||
p.slides.push(Arc::new(slide));
|
||||
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
let back = deckcraft_pptx::import(&bytes).expect("import");
|
||||
let c = chart_of(&back);
|
||||
assert!(c.data_labels);
|
||||
assert_eq!(c.series[0].data_labels, Some(false), "the explicit off survives");
|
||||
assert_eq!(c.series[1].data_labels, Some(true), "the explicit on survives");
|
||||
assert!(!c.series_data_labels(&c.series[0]));
|
||||
assert!(c.series_data_labels(&c.series[1]));
|
||||
}
|
||||
|
||||
/// A one-slide deck with a combo chart: a bar group and a line group.
|
||||
fn combo_chart_pptx() -> Vec<u8> {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
p.slides.clear();
|
||||
let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default();
|
||||
let mut slide = defaults::new_slide(&mut p, layout).expect("slide");
|
||||
let mut chart = Chart::sample(ChartType::Combo);
|
||||
chart.series.truncate(2);
|
||||
chart.series[1].kind = Some(ChartType::Line);
|
||||
let mut shape =
|
||||
Shape { id: ShapeId(p.alloc_id().expect("id")), name: "Bars".into(), xfrm: Some(Xfrm::new(40.0, 40.0, 400.0, 300.0)), ..Default::default() };
|
||||
shape.kind = ShapeKind::Chart(Box::new(chart));
|
||||
slide.shapes.push(shape);
|
||||
p.slides.push(Arc::new(slide));
|
||||
deckcraft_pptx::export(&p).expect("export")
|
||||
}
|
||||
|
||||
/// A combo chart's groups keep their own label setting: the group that asks for no labels
|
||||
/// keeps them off, even when the other group asks for them (#868).
|
||||
#[test]
|
||||
fn a_combo_chart_keeps_each_groups_own_label_setting() {
|
||||
let p = reimport(&combo_chart_pptx(), |xml| {
|
||||
// The bar group asks for no labels, the line group for labels.
|
||||
xml.replacen("<c:gapWidth", r#"<c:dLbls><c:showVal val="0"/></c:dLbls><c:gapWidth"#, 1).replacen(
|
||||
"<c:marker val=\"1\"/>",
|
||||
r#"<c:dLbls><c:showVal/></c:dLbls><c:marker val="1"/>"#,
|
||||
1,
|
||||
)
|
||||
});
|
||||
let c = chart_of(&p);
|
||||
assert!(c.data_labels, "the line group's ask turns the chart-wide setting on");
|
||||
assert_eq!(c.series[0].data_labels, Some(false), "the bar group asks for no labels");
|
||||
assert!(!c.series_data_labels(&c.series[0]), "so the bars stay quiet");
|
||||
assert!(c.series_data_labels(&c.series[1]), "the line takes the chart-wide on");
|
||||
}
|
||||
|
||||
/// Turning Data Labels on through `chart.options` shows the labels of series that the file
|
||||
/// switched off one by one.
|
||||
#[test]
|
||||
fn chart_options_data_labels_on_overrides_series_off() {
|
||||
use deckcraft_engine::Session;
|
||||
let p = reimport(&bar_chart_pptx(), |xml| xml.replace("<c:cat>", r#"<c:dLbls><c:showVal val="0"/></c:dLbls><c:cat>"#));
|
||||
assert!(chart_of(&p).series.iter().all(|s| !chart_of(&p).series_data_labels(s)), "every series starts quiet");
|
||||
let path = std::env::temp_dir().join("deckcraft_series_labels_off.pptx");
|
||||
std::fs::write(&path, deckcraft_pptx::export(&p).expect("export")).expect("write");
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.open", &serde_json::json!({"path": path.to_string_lossy()})).expect("open");
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let id = doc.slides[0].shapes.iter().find(|sh| matches!(sh.kind, ShapeKind::Chart(_))).expect("chart").id.0;
|
||||
assert!(chart_of(&doc).series.iter().all(|x| !chart_of(&doc).series_data_labels(x)));
|
||||
s.execute("chart.options", &serde_json::json!({"id": id, "dataLabels": true})).expect("options");
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let c = chart_of(&doc);
|
||||
assert!(c.series.iter().all(|x| c.series_data_labels(x)), "every series shows its labels");
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
+126
-41
@@ -29,31 +29,70 @@ pub fn series_color(ctx: &Ctx, c: &Chart, i: usize) -> Rgba {
|
||||
}
|
||||
}
|
||||
|
||||
/// A series' one colour (lines, areas, markers): its solid fill, a pattern's foreground, else the
|
||||
/// palette's.
|
||||
pub(crate) fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart::Series>) -> Rgba {
|
||||
match s.and_then(|s| s.fill.as_ref()) {
|
||||
Some(Fill::Solid { color: cc }) => ctx.color(cc, None),
|
||||
Some(Fill::Pattern(p)) => ctx.color(&p.fg, None),
|
||||
_ => series_color(ctx, c, i),
|
||||
}
|
||||
}
|
||||
|
||||
/// The fill of point `k` of a series: its own (`c:dPt`) where the file gives one, else the series'.
|
||||
fn point_fill(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series, k: usize) -> Rgba {
|
||||
match s.point_fills.iter().find(|(p, _)| *p == k).map(|(_, f)| f) {
|
||||
Some(Fill::Solid { color: cc }) => ctx.color(cc, None),
|
||||
_ => series_fill(ctx, c, i, Some(s)),
|
||||
/// How a bar, or its legend key, is painted.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Paint {
|
||||
Color(Rgba),
|
||||
/// A preset pattern: name, foreground, background.
|
||||
Pattern(String, Rgba, Rgba),
|
||||
/// `a:noFill`: only the outline, if any, shows.
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// The paint of fill `f` as a bar or legend key draws it; the palette's `fallback` when the file
|
||||
/// gives none (or one a bar can't show).
|
||||
fn paint_of(ctx: &Ctx, f: Option<&Fill>, fallback: Rgba) -> Paint {
|
||||
match f {
|
||||
Some(Fill::Solid { color: cc }) => Paint::Color(ctx.color(cc, None)),
|
||||
Some(Fill::Pattern(p)) => Paint::Pattern(p.preset.clone(), ctx.color(&p.fg, None), ctx.color(&p.bg, None)),
|
||||
Some(Fill::None) => Paint::Nothing,
|
||||
_ => Paint::Color(fallback),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill of one bar: a `<c:dPt>` fill wins, and a single-series chart that varies colours by point uses one accent per point
|
||||
/// (not waterfalls, which are never read from a `<c:barChart>`).
|
||||
fn bar_fill(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series) -> Rgba {
|
||||
if s.point_fills.iter().any(|(k, _)| *k == pi) {
|
||||
return point_fill(ctx, c, si, s, pi);
|
||||
/// Fill `r` (in the space `m` maps to the output) with `p`.
|
||||
fn fill_with(ctx: &mut RenderContext, p: &Paint, r: Rect, m: Affine) {
|
||||
match p {
|
||||
Paint::Color(c) => {
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(*c, 1.0));
|
||||
ctx.fill_rect(&r);
|
||||
}
|
||||
Paint::Pattern(name, fg, bg) => crate::pattern::fill(ctx, name, *fg, *bg, 1.0, &r.to_path(0.1), m),
|
||||
Paint::Nothing => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A bar series' outline (`c:spPr/a:ln`) where the file gives it a solid colour: colour and width.
|
||||
fn bar_outline(ctx: &Ctx, s: &deckcraft_model::chart::Series) -> Option<(Rgba, f64)> {
|
||||
let l = s.line.as_ref()?;
|
||||
match l.fill.as_ref()? {
|
||||
Fill::Solid { color: cc } => Some((ctx.color(cc, None), l.width.unwrap_or(0.75).clamp(0.0, 20.0))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint of one bar: a `<c:dPt>` fill wins, and a single-series chart that varies colours by point uses one accent per point
|
||||
/// (not waterfalls, which are never read from a `<c:barChart>`). Pattern fills draw as patterns and
|
||||
/// `a:noFill` leaves the bar empty, as in PowerPoint.
|
||||
fn bar_paint(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series) -> Paint {
|
||||
if let Some((_, f)) = s.point_fills.iter().find(|(k, _)| *k == pi) {
|
||||
return paint_of(ctx, Some(f), series_fill(ctx, c, si, Some(s)));
|
||||
}
|
||||
if c.vary_colors && c.series.len() == 1 && s.fill.is_none() && c.kind != ChartType::Waterfall {
|
||||
return series_color(ctx, c, pi);
|
||||
return Paint::Color(series_color(ctx, c, pi));
|
||||
}
|
||||
series_fill(ctx, c, si, Some(s))
|
||||
paint_of(ctx, s.fill.as_ref(), series_color(ctx, c, si))
|
||||
}
|
||||
|
||||
fn polyline(pts: &[Point]) -> BezPath {
|
||||
@@ -193,6 +232,17 @@ fn legend_layout(pos: &str, size: (f64, f64), area: Rect, base: f64, overlay: bo
|
||||
(origin, if overlay { area } else { plot })
|
||||
}
|
||||
|
||||
/// An axis span to divide by: the span itself when positive, else 1 (no absolute floor, so tiny data keeps its scale).
|
||||
pub(crate) fn positive_span(s: f64) -> f64 {
|
||||
if s > 0.0 && s.is_finite() { s } else { 1.0 }
|
||||
}
|
||||
|
||||
/// The tick values of an axis (min, max, major step), one per step (at most 51).
|
||||
fn axis_ticks((lo, hi, st): (f64, f64, f64)) -> impl Iterator<Item = f64> {
|
||||
let n = if st > 0.0 { (((hi - lo) / st).round() as i64).clamp(1, 50) } else { 1 };
|
||||
(0..=n).map(move |k| lo + k as f64 * st)
|
||||
}
|
||||
|
||||
pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64) {
|
||||
match c.kind {
|
||||
// Histograms and Pareto charts are drawn as the column (and line) chart of their bins.
|
||||
@@ -205,9 +255,19 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
}
|
||||
}
|
||||
|
||||
/// PowerPoint's default chart text colour: `tx1` with `lumMod` 65000 and `lumOff` 35000, which
|
||||
/// is 595959 on a light theme and a light grey on a dark one.
|
||||
pub fn default_text_color() -> ColorRef {
|
||||
use deckcraft_color::ColorTransform;
|
||||
ColorRef::scheme(SchemeSlot::Tx1).with(ColorTransform::LumMod(65000)).with(ColorTransform::LumOff(35000))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64, boxes: Option<&crate::chartex::Boxes>) {
|
||||
let text_col = c.text_color.as_ref().map(|t| rctx.color(t, None)).unwrap_or(Rgba::rgb(0x59, 0x59, 0x59));
|
||||
// Text without a colour of its own takes PowerPoint's default, the theme's text colour at
|
||||
// lumMod 65 % / lumOff 35 % (595959 on a light theme, light grey on a dark one), so a chart
|
||||
// on a dark theme is readable (a fixed grey used to vanish there).
|
||||
let text_col = rctx.color(c.text_color.as_ref().unwrap_or(&default_text_color()), None);
|
||||
let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9);
|
||||
if let Some(f) = &c.chart_fill {
|
||||
crate::paint::fill_path(
|
||||
@@ -235,16 +295,17 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut);
|
||||
// Legend placed by `c:legendPos`.
|
||||
let waterfall = c.kind == ChartType::Waterfall;
|
||||
let legend_items: Vec<(String, Rgba)> = if waterfall {
|
||||
["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), series_color(rctx, c, i))).collect()
|
||||
// Each legend entry: its name, its key's paint and the key's outline.
|
||||
let legend_items: Vec<(String, Paint, Option<(Rgba, f64)>)> = if waterfall {
|
||||
["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), Paint::Color(series_color(rctx, c, i)), None)).collect()
|
||||
} else if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) {
|
||||
// A hierarchy's legend names its top-level groups.
|
||||
c.category_tree().iter().enumerate().map(|(i, n)| (n.name.clone(), series_color(rctx, c, i))).collect()
|
||||
c.category_tree().iter().enumerate().map(|(i, n)| (n.name.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect()
|
||||
} else if matches!(c.kind, ChartType::Funnel | ChartType::RegionMap) {
|
||||
// A map draws its own colour scale.
|
||||
vec![]
|
||||
} else if pie {
|
||||
c.categories.iter().enumerate().map(|(i, n)| (n.clone(), series_color(rctx, c, i))).collect()
|
||||
c.categories.iter().enumerate().map(|(i, n)| (n.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect()
|
||||
} else {
|
||||
c.series
|
||||
.iter()
|
||||
@@ -252,13 +313,24 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
.map(|(i, s)| {
|
||||
let k = c.series_kind(s);
|
||||
let line = !k.is_bar_like() && !matches!(k, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar);
|
||||
(s.name.clone(), if line { series_line(rctx, c, i, s) } else { series_fill(rctx, c, i, Some(s)) })
|
||||
if line {
|
||||
(s.name.clone(), Paint::Color(series_line(rctx, c, i, s)), None)
|
||||
} else if k.is_bar_like() {
|
||||
let paint = if c.vary_colors && c.series.len() == 1 && s.fill.is_none() {
|
||||
Paint::Color(series_color(rctx, c, i))
|
||||
} else {
|
||||
paint_of(rctx, s.fill.as_ref(), series_color(rctx, c, i))
|
||||
};
|
||||
(s.name.clone(), paint, bar_outline(rctx, s))
|
||||
} else {
|
||||
(s.name.clone(), Paint::Color(series_fill(rctx, c, i, Some(s))), None)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if let (Some(pos), false) = (&c.legend, legend_items.is_empty()) {
|
||||
let vertical = matches!(pos.as_str(), "l" | "r" | "tr");
|
||||
let item_ws: Vec<f64> = legend_items.iter().map(|(n, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect();
|
||||
let item_ws: Vec<f64> = legend_items.iter().map(|(n, _, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect();
|
||||
let size = if vertical {
|
||||
(item_ws.iter().copied().fold(0.0, f64::max).min(w * 0.4), legend_items.len() as f64 * base * 1.5)
|
||||
} else {
|
||||
@@ -267,10 +339,15 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
let (origin, plot_area) = legend_layout(pos, size, area, base, c.legend_overlay);
|
||||
area = plot_area;
|
||||
let (mut x, mut y) = (origin.x, origin.y);
|
||||
for (name, col) in &legend_items {
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(*col, 1.0));
|
||||
ctx.fill_rect(&Rect::new(x, y + base * 0.3, x + base * 0.7, y + base));
|
||||
for (name, paint, outline) in &legend_items {
|
||||
let key = Rect::new(x, y + base * 0.3, x + base * 0.7, y + base);
|
||||
fill_with(ctx, paint, key, m);
|
||||
if let Some((oc, ow)) = outline.filter(|o| o.1 > 0.0) {
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(oc, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(ow.min(base * 0.2)));
|
||||
ctx.stroke_path(&key.to_path(0.1));
|
||||
}
|
||||
let tw = name.chars().count() as f64 * base * 0.55 + base;
|
||||
label(ctx, rctx, m, name, Rect::new(x + base, y, x + base + tw, y + base * 1.3), base, Align::Left, Anchor::Middle, text_col, false);
|
||||
if vertical {
|
||||
@@ -310,7 +387,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
ctx.set_paint(color(Rgba::WHITE, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(1.0));
|
||||
ctx.stroke_path(&p);
|
||||
if c.data_labels {
|
||||
if c.series_data_labels(s) {
|
||||
let mid = a0 + sweep / 2.0;
|
||||
let lp = Point::new(cen.x + r * 0.65 * mid.cos(), cen.y + r * 0.65 * mid.sin());
|
||||
label(
|
||||
@@ -322,7 +399,8 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
base,
|
||||
Align::Center,
|
||||
Anchor::Middle,
|
||||
Rgba::WHITE,
|
||||
// On the slices themselves: white, unless the chart's text was given a colour.
|
||||
if c.text_color.is_some() { text_col } else { Rgba::WHITE },
|
||||
false,
|
||||
);
|
||||
}
|
||||
@@ -379,10 +457,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
};
|
||||
// A combo chart's secondary value axis, on the right (vertical value axes only).
|
||||
let second = if horiz { None } else { c.axis_scale(true, max_intervals) };
|
||||
let ticks = |(lo, hi, st): (f64, f64, f64)| {
|
||||
let n = (((hi - lo) / st.max(1e-12)).round() as i64).clamp(1, 50);
|
||||
(0..=n).map(move |k| lo + k as f64 * st)
|
||||
};
|
||||
let ticks = axis_ticks;
|
||||
// Label gutters as wide as the longest value label.
|
||||
let primary_ticks: Vec<f64> = ticks((min, max, step)).map(|k| log.map_or(k, |(b, _)| b.powf(k))).collect();
|
||||
let label_w = |sec: bool, sc: (f64, f64, f64)| {
|
||||
@@ -405,7 +480,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
if let Some(f) = &c.plot_fill {
|
||||
crate::paint::fill_path(ctx, rctx, f, None, &plot.to_path(0.1), plot, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres);
|
||||
}
|
||||
let span = (max - min).max(1e-9);
|
||||
let span = positive_span(max - min);
|
||||
// Values past the axis limits (fixed ones, or zero on an axis that starts above it) stop at
|
||||
// the plot's edge.
|
||||
let to_v = |v: f64| {
|
||||
@@ -414,13 +489,13 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
};
|
||||
// A series' value as a position on its own axis.
|
||||
let to_axis = |sec: bool, v: f64| match (sec, second) {
|
||||
(true, Some((min2, max2, _))) => plot.y1 - (v.max(min2).min(max2) - min2) / (max2 - min2).max(1e-9) * plot.height(),
|
||||
(true, Some((min2, max2, _))) => plot.y1 - (v.max(min2).min(max2) - min2) / positive_span(max2 - min2) * plot.height(),
|
||||
_ => to_v(v),
|
||||
};
|
||||
// Minor gridlines, under the major ones: five to a major unit.
|
||||
if c.minor_gridlines {
|
||||
let minor = step / 5.0;
|
||||
let n = (((max - min) / minor.max(1e-12)).round() as i64).clamp(1, 500);
|
||||
let n = if minor > 0.0 { (((max - min) / minor).round() as i64).clamp(1, 500) } else { 1 };
|
||||
let mut g = BezPath::new();
|
||||
for k in (0..=n).filter(|k| k % 5 != 0) {
|
||||
// On a logarithmic axis `min`, `max` and `step` are exponents of the base.
|
||||
@@ -514,10 +589,10 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
// Scatter and bubble charts have a value x axis: one scale over every series' x values, with its
|
||||
// own tick labels in place of category names.
|
||||
let xscale = if horiz { None } else { value_x_scale(c, (plot.width() / (base * 2.5)).floor().clamp(2.0, 12.0) as usize) };
|
||||
let to_x = |v: f64| xscale.map(|(lo, hi, _)| plot.x0 + (v - lo) / (hi - lo).max(1e-9) * plot.width());
|
||||
let to_x = |v: f64| xscale.map(|(lo, hi, _)| plot.x0 + (v - lo) / positive_span(hi - lo) * plot.width());
|
||||
if let Some(sc) = xscale {
|
||||
for v in ticks(sc) {
|
||||
let pos = plot.x0 + (v - sc.0) / (sc.1 - sc.0).max(1e-9) * plot.width();
|
||||
let pos = plot.x0 + (v - sc.0) / positive_span(sc.1 - sc.0) * plot.width();
|
||||
label(
|
||||
ctx,
|
||||
rctx,
|
||||
@@ -625,7 +700,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
.abs();
|
||||
// A waterfall colours increases, decreases and totals, and joins each bar's end
|
||||
// to the next bar's start.
|
||||
let fill = if waterfall {
|
||||
let paint = if waterfall {
|
||||
let slot = if c.subtotals.contains(&i) {
|
||||
2
|
||||
} else if b < a {
|
||||
@@ -633,13 +708,17 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
} else {
|
||||
0
|
||||
};
|
||||
series_color(rctx, c, slot)
|
||||
Paint::Color(series_color(rctx, c, slot))
|
||||
} else {
|
||||
bar_fill(rctx, c, *si, i, s)
|
||||
bar_paint(rctx, c, *si, i, s)
|
||||
};
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(fill, 1.0));
|
||||
ctx.fill_rect(&r.abs());
|
||||
fill_with(ctx, &paint, r.abs(), m);
|
||||
if !waterfall && let Some((oc, ow)) = bar_outline(rctx, s).filter(|o| o.1 > 0.0) {
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(oc, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(ow));
|
||||
ctx.stroke_path(&r.abs().to_path(0.1));
|
||||
}
|
||||
if waterfall && i + 1 < ncat && !horiz {
|
||||
let y = to_v(b);
|
||||
let mut j = BezPath::new();
|
||||
@@ -656,7 +735,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
err_marks.push((mid, p0, p1, bar.min(base * 0.8) * 0.5, e.end_cap));
|
||||
}
|
||||
}
|
||||
if c.data_labels {
|
||||
if c.series_data_labels(s) {
|
||||
let t = c.data_label(s, s.values.get(i).copied().flatten().unwrap_or(0.0));
|
||||
let lr = if horiz {
|
||||
Rect::new(r.x1 + 2.0, r.y0, r.x1 + 40.0, r.y1)
|
||||
@@ -1055,6 +1134,12 @@ mod tests {
|
||||
|
||||
const AREA: Rect = Rect::new(0.0, 0.0, 400.0, 300.0);
|
||||
|
||||
#[test]
|
||||
fn tiny_data_gets_several_ticks() {
|
||||
let sc = deckcraft_model::chart::axis_scale(1e-13, 5e-13, None, None, None, 8, true);
|
||||
assert!(axis_ticks(sc).count() >= 3, "{sc:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scatter_x_is_a_value_scale_over_all_series() {
|
||||
let mut c = Chart::sample(ChartType::Scatter);
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec
|
||||
return;
|
||||
}
|
||||
let value_label = |v: f64| c.data_label(s, v);
|
||||
let labels = c.data_labels || c.kind == ChartType::Funnel;
|
||||
let labels = c.series_data_labels(s) || c.kind == ChartType::Funnel;
|
||||
match c.kind {
|
||||
ChartType::Funnel => funnel(ctx, rctx, c, m, area, base, text_col, &vals, &value_label),
|
||||
ChartType::Treemap if !c.category_levels.is_empty() => tree_treemap(ctx, rctx, c, m, area, base, labels.then_some(&value_label)),
|
||||
@@ -107,7 +107,7 @@ fn region_tiles(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area:
|
||||
ctx.fill_rect(&t);
|
||||
let dark = v.is_some_and(|v| (v - min) / span > 0.55);
|
||||
let mut text = name.clone();
|
||||
if c.data_labels
|
||||
if c.series_data_labels(s)
|
||||
&& let Some(v) = v
|
||||
{
|
||||
text = format!(
|
||||
|
||||
@@ -1152,7 +1152,7 @@ impl Renderer {
|
||||
}
|
||||
_ => {
|
||||
if let Some(fl) = &fill
|
||||
&& !(empty_ph && f.opts.edit && s.fill.is_none())
|
||||
&& !(empty_ph && f.opts.edit && s.fill.is_none() && s.line.is_none() && s.style.is_none())
|
||||
{
|
||||
for (i, sp) in geo.paths.iter().enumerate() {
|
||||
if sp.fill == FillMode::None || fade(i).fill <= 0.0 {
|
||||
|
||||
@@ -28,8 +28,8 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec
|
||||
let r = (area.width().min(area.height()) / 2.0 - base * 1.9).max(base);
|
||||
let cen = area.center();
|
||||
let (min, max, step) = c.axis_scale(false, 5).unwrap_or((0.0, 1.0, 0.2));
|
||||
let span = (max - min).max(1e-9);
|
||||
let ticks = (((max - min) / step.max(1e-12)).round() as i64).clamp(1, 20);
|
||||
let span = crate::chart::positive_span(max - min);
|
||||
let ticks = if step > 0.0 { (((max - min) / step).round() as i64).clamp(1, 20) } else { 1 };
|
||||
ctx.set_transform(m);
|
||||
ctx.set_stroke(kurbo::Stroke::new(0.75));
|
||||
ctx.set_paint(color(grid_col, 1.0));
|
||||
@@ -120,7 +120,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.data_labels || s.data_labels == Some(true) {
|
||||
if c.series_data_labels(s) {
|
||||
for (i, q) in pts.iter().enumerate() {
|
||||
if let Some(v) = s.values.get(i).copied().flatten() {
|
||||
let t = c.data_label(s, v);
|
||||
|
||||
+44
-27
@@ -153,18 +153,28 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
|
||||
let band_c = t.band_col && c.is_multiple_of(2);
|
||||
let tint = |k: i32| deckcraft_color::apply(a, &[ColorTransform::Tint(k)]);
|
||||
let white = Rgba::WHITE;
|
||||
let dark_text = ctx.color(&ColorRef::scheme(SchemeSlot::Tx1), None);
|
||||
let text_color = ctx.color(&ColorRef::scheme(SchemeSlot::Tx1), None);
|
||||
// Text on a tinted cell never inherits the text colour, which a dark theme makes light and
|
||||
// unreadable on the light tint: PowerPoint's medium styles set it to the theme's dark colour
|
||||
// (dk1). A dark theme may keep that in dk1 and map tx1 away from it, or (Nocturne) put it in
|
||||
// lt1, so of the two the one contrasting more with the cell's fill is used.
|
||||
let dk1 = ctx.color(&ColorRef::scheme(SchemeSlot::Dk1), None);
|
||||
let lt1 = ctx.color(&ColorRef::scheme(SchemeSlot::Lt1), None);
|
||||
let on = |fill: Rgba| if dk1.contrast(fill) >= lt1.contrast(fill) { dk1 } else { lt1 };
|
||||
match kind {
|
||||
"none" => CellLook { fill: None, text: None, bold: false, border: None, sides: [None; 4], paint: None },
|
||||
"grid" => CellLook { fill: None, text: None, bold: false, border: Some((dark_text, 0.75)), sides: [None; 4], paint: None },
|
||||
"light1" => CellLook {
|
||||
fill: if band || band_c { Some(tint(20000)) } else { None },
|
||||
text: None,
|
||||
bold: header || total || first_col || last_col,
|
||||
border: if header { Some((a, 1.0)) } else { None },
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
},
|
||||
"grid" => CellLook { fill: None, text: None, bold: false, border: Some((text_color, 0.75)), sides: [None; 4], paint: None },
|
||||
"light1" => {
|
||||
let fill = (band || band_c).then(|| tint(20000));
|
||||
CellLook {
|
||||
fill,
|
||||
text: fill.map(on),
|
||||
bold: header || total || first_col || last_col,
|
||||
border: if header { Some((a, 1.0)) } else { None },
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
}
|
||||
}
|
||||
"light2" => CellLook {
|
||||
fill: if header { Some(a) } else { None },
|
||||
text: if header { Some(white) } else { None },
|
||||
@@ -187,29 +197,36 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
},
|
||||
"medium4" => CellLook {
|
||||
fill: Some(if header || band || band_c { tint(40000) } else { tint(20000) }),
|
||||
text: None,
|
||||
bold: header || total || first_col || last_col,
|
||||
border: Some((a, 0.75)),
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
},
|
||||
_ => CellLook {
|
||||
"medium4" => {
|
||||
let fill = if header || band || band_c { tint(40000) } else { tint(20000) };
|
||||
CellLook {
|
||||
fill: Some(fill),
|
||||
text: Some(on(fill)),
|
||||
bold: header || total || first_col || last_col,
|
||||
border: Some((a, 0.75)),
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// medium2: strong header, banded light tints, white grid.
|
||||
fill: Some(if header || total || first_col || last_col {
|
||||
let strong = header || total || first_col || last_col;
|
||||
let fill = if strong {
|
||||
a
|
||||
} else if band || band_c {
|
||||
tint(40000)
|
||||
} else {
|
||||
tint(20000)
|
||||
}),
|
||||
text: if header || total || first_col || last_col { Some(white) } else { None },
|
||||
bold: header || total || first_col || last_col,
|
||||
border: Some((white, 1.0)),
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
},
|
||||
};
|
||||
CellLook {
|
||||
fill: Some(fill),
|
||||
text: Some(if strong { white } else { on(fill) }),
|
||||
bold: strong,
|
||||
border: Some((white, 1.0)),
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+158
-1
@@ -2,7 +2,7 @@ use super::*;
|
||||
use deckcraft_model::chart::{ChartType, Trendline, TrendlineType};
|
||||
use deckcraft_model::style::ColorRef;
|
||||
use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{Chart, Geom, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle, Table, Xfrm};
|
||||
use deckcraft_model::{Chart, Geom, PhType, Placeholder, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle, Table, Xfrm};
|
||||
|
||||
fn deck_with(shapes: Vec<Shape>) -> Presentation {
|
||||
let mut p = Presentation::default();
|
||||
@@ -35,6 +35,33 @@ fn shape_fill_uses_theme_accent() {
|
||||
assert_eq!(img.pixel(50, 50), [255, 255, 255, 255]);
|
||||
}
|
||||
|
||||
/// #832: a quick style on an empty placeholder paints its fill. The style reference
|
||||
/// (`s.style`) carries the fill, but the empty-placeholder gate skipped the fill whenever the
|
||||
/// shape had no local fill, so the quick style only appeared once text was added. A
|
||||
/// placeholder with no style at all still shows nothing but its dashed outline.
|
||||
#[test]
|
||||
fn a_quick_style_fills_an_empty_placeholder() {
|
||||
let placeholder = || Shape {
|
||||
id: ShapeId(910),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
|
||||
ph: Some(Placeholder { kind: PhType::Body, idx: 1, ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
let p = deck_with(vec![Shape { style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)), ..placeholder() }]);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, edit: true, ..Default::default() });
|
||||
let accent = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1);
|
||||
// Lower right of the shape: the prompt text sits at the top of the text rect.
|
||||
let px = img.pixel(280, 185);
|
||||
assert!(
|
||||
(px[0] as i32 - accent.r as i32).abs() <= 2 && (px[1] as i32 - accent.g as i32).abs() <= 2 && (px[2] as i32 - accent.b as i32).abs() <= 2,
|
||||
"the quick style's fill is drawn on the empty placeholder: {px:?} vs {accent:?}"
|
||||
);
|
||||
// An unformatted empty placeholder stays empty: the gate is not simply gone.
|
||||
let p = deck_with(vec![placeholder()]);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, edit: true, ..Default::default() });
|
||||
assert_eq!(img.pixel(280, 185), [255, 255, 255, 255], "an unformatted placeholder has no fill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_shapes_draws_the_selection_on_a_transparent_canvas() {
|
||||
let sh = Shape {
|
||||
@@ -340,6 +367,43 @@ fn chart_axis_titles_are_drawn() {
|
||||
assert!(ink(&titled, bottom) > ink(&bare, bottom) + 30, "category axis title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_series_draw_their_pattern_fill_and_no_fill_with_their_outline() {
|
||||
// Three column series, as PowerPoint draws them: a red-on-yellow pattern, no fill with a blue
|
||||
// outline, and no fill at all. None takes a palette colour; the legend keys match the bars.
|
||||
use deckcraft_model::style::{Fill, PatternFill};
|
||||
let rgb = |r, g, b| ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b));
|
||||
let mut c = Chart::sample(ChartType::Column);
|
||||
c.title = None;
|
||||
c.series.truncate(3);
|
||||
c.categories = vec!["A".into()];
|
||||
for s in &mut c.series {
|
||||
s.values = vec![Some(10.0)];
|
||||
}
|
||||
c.series[0].fill = Some(Fill::Pattern(PatternFill { preset: "dkDnDiag".into(), fg: rgb(255, 0, 0), bg: rgb(255, 255, 0) }));
|
||||
c.series[1].fill = Some(Fill::None);
|
||||
c.series[1].line = Some(deckcraft_model::style::Line { width: Some(3.0), fill: Some(Fill::solid(rgb(0, 0, 255))), ..Default::default() });
|
||||
c.series[2].fill = Some(Fill::None);
|
||||
let sh =
|
||||
Shape { id: ShapeId(625), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c.clone())), ..Default::default() };
|
||||
let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let count =
|
||||
|f: &dyn Fn([u8; 4]) -> bool| (0..img.width).flat_map(|x| (0..img.height).map(move |y| (x, y))).filter(|(x, y)| f(img.pixel(*x, *y))).count();
|
||||
let red = count(&|p| p[0] > 220 && p[1] < 40 && p[2] < 40);
|
||||
let yellow = count(&|p| p[0] > 220 && p[1] > 220 && p[2] < 40);
|
||||
let blue = count(&|p| p[0] < 40 && p[1] < 40 && p[2] > 220);
|
||||
assert!(red > 1000 && yellow > 1000, "the pattern's two colours: {red} red, {yellow} yellow");
|
||||
assert!(blue > 300, "the empty series' outline: {blue}");
|
||||
// No palette colour: the theme's accents would show for any series drawn the old way.
|
||||
let p = deck_with(vec![]);
|
||||
let ctx = deckcraft_model::resolve::Ctx::for_slide(&p, &p.slides[0]).unwrap();
|
||||
for i in 0..3 {
|
||||
let a = crate::chart::series_color(&ctx, &c, i);
|
||||
let n = count(&|q| q[0].abs_diff(a.r) < 6 && q[1].abs_diff(a.g) < 6 && q[2].abs_diff(a.b) < 6);
|
||||
assert!(n < 50, "palette colour {i} drawn on {n} pixels");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_points_take_their_own_fill_and_smooth_lines_curve() {
|
||||
// One column series where only the middle point is given a red c:dPt fill.
|
||||
@@ -1336,6 +1400,41 @@ fn a_table_fill_shows_behind_cells_without_their_own() {
|
||||
assert_eq!(img.pixel(50, 50)[..3], [255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_body_text_is_dark_on_the_tinted_cells_of_a_dark_theme() {
|
||||
// The default style (medium2) tints body cells towards white, so their text must be the
|
||||
// theme's dark colour, as PowerPoint's medium styles set it (dk1): inheriting the text colour
|
||||
// leaves it light, and unreadable, on a dark theme such as Nocturne.
|
||||
let fill_and_darkest = |theme: &str| {
|
||||
let mut t = Table::new(2, 2, 200.0, 40.0);
|
||||
for (r, c) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
|
||||
t.cell_mut(r, c).unwrap().text = TextBody::from_text("Body");
|
||||
}
|
||||
let tbl = Shape { id: ShapeId(796), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 80.0)), kind: ShapeKind::Table(t), ..Default::default() };
|
||||
let mut p = deck_with(vec![tbl]);
|
||||
let th = deckcraft_model::theme::builtin_themes().into_iter().find(|th| th.name == theme).unwrap();
|
||||
std::sync::Arc::make_mut(&mut p.masters[0]).theme = th;
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let lum = |x, y| {
|
||||
let px = img.pixel(x, y);
|
||||
deckcraft_color::Rgba::rgb(px[0], px[1], px[2]).luminance()
|
||||
};
|
||||
// Body cell (1, 1) covers (200, 140)–(300, 180); its top-left corner is clear of the text
|
||||
// (cell margins) and of the white grid line.
|
||||
let fill = lum(203, 143);
|
||||
let darkest = (143..177).flat_map(|y| (203..297).map(move |x| (x, y))).map(|(x, y)| lum(x, y)).fold(1.0_f64, f64::min);
|
||||
(fill, darkest)
|
||||
};
|
||||
for theme in ["Nocturne", "Harbor"] {
|
||||
let (fill, darkest) = fill_and_darkest(theme);
|
||||
assert!(fill > 0.4, "{theme}: the body cell's fill is a light tint, luminance {fill}");
|
||||
assert!(
|
||||
darkest < 0.15 && darkest < fill / 3.0,
|
||||
"{theme}: darkest pixel in the body cell has luminance {darkest} on a fill of {fill}: its text is not dark"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A white 280x200 pt rectangle at (20, 20) with `effects`, rendered at 1 px/pt.
|
||||
fn effect_box(effects: deckcraft_model::Effects) -> Image {
|
||||
use deckcraft_model::style::{Fill, Line};
|
||||
@@ -2717,3 +2816,61 @@ fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() {
|
||||
assert!(changed.iter().any(|(px, _)| px[0] == 179 && px[1] == 179 && px[2] == 179));
|
||||
assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2]));
|
||||
}
|
||||
|
||||
/// A chart that has no text colour of its own takes PowerPoint's default, the theme's text colour
|
||||
/// at 65 % plus 35 %: light on Nocturne's dark slides (it used to be a fixed grey, near-invisible
|
||||
/// there), 595959 grey on Harbor's light ones.
|
||||
#[test]
|
||||
fn chart_text_follows_the_theme_when_it_has_no_colour_of_its_own() {
|
||||
let draw = |theme: &str, title: Option<&str>| {
|
||||
let mut c = Chart::sample(ChartType::Column);
|
||||
c.title = title.map(String::from);
|
||||
c.legend = None;
|
||||
c.data_labels = false;
|
||||
let sh = Shape { id: ShapeId(630), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() };
|
||||
let mut p = deck_with(vec![sh]);
|
||||
let t = deckcraft_model::theme::builtin_themes().into_iter().find(|t| t.name == theme).expect("theme");
|
||||
std::sync::Arc::make_mut(&mut p.masters[0]).theme = t;
|
||||
render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||||
};
|
||||
let lum = |px: [u8; 4]| (px[0] as u32 * 299 + px[1] as u32 * 587 + px[2] as u32 * 114) / 1000;
|
||||
// Pixels in the title band that pass `f`.
|
||||
let count = |img: &Image, f: &dyn Fn(u32) -> bool| {
|
||||
(200..760u32).flat_map(|x| (4..70u32).map(move |y| (x, y))).filter(|(x, y)| f(lum(img.pixel(*x, *y)))).count()
|
||||
};
|
||||
let (dark_bare, dark_titled) = (draw("Nocturne", None), draw("Nocturne", Some("Quarterly revenue")));
|
||||
assert!(lum(dark_bare.pixel(5, 5)) < 60, "Nocturne's slide is dark: {:?}", dark_bare.pixel(5, 5));
|
||||
let light_ink = count(&dark_titled, &|l| l > 200) as i64 - count(&dark_bare, &|l| l > 200) as i64;
|
||||
assert!(light_ink > 100, "the title is light on a dark theme (light pixels added: {light_ink})");
|
||||
let (light_bare, light_titled) = (draw("Harbor", None), draw("Harbor", Some("Quarterly revenue")));
|
||||
assert!(lum(light_bare.pixel(5, 5)) > 200, "Harbor's slide is light: {:?}", light_bare.pixel(5, 5));
|
||||
// 595959 has luminance 89: dark, but not black.
|
||||
let dark_ink = count(&light_titled, &|l| l < 120) as i64 - count(&light_bare, &|l| l < 120) as i64;
|
||||
assert!(dark_ink > 100, "the title is dark on a light theme (dark pixels added: {dark_ink})");
|
||||
let black_ink = count(&light_titled, &|l| l < 40) as i64 - count(&light_bare, &|l| l < 40) as i64;
|
||||
assert!(black_ink < 20, "the title is PowerPoint's 595959 grey, not black (black pixels added: {black_ink})");
|
||||
}
|
||||
|
||||
/// Pie data labels sit on the slices in white; a colour given to the chart's text (chart.options
|
||||
/// textColor, Font Color on the chart) colours them too, as it does the title and legend.
|
||||
#[test]
|
||||
fn pie_data_labels_take_the_chart_text_colour_when_it_has_one() {
|
||||
let draw = |colour: Option<ColorRef>| {
|
||||
let mut c = Chart::sample(ChartType::Pie);
|
||||
c.title = None;
|
||||
c.legend = None;
|
||||
c.data_labels = true;
|
||||
c.text_color = colour;
|
||||
let sh = Shape { id: ShapeId(632), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0, 400.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() };
|
||||
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||||
};
|
||||
let count = |img: &Image, f: &dyn Fn([u8; 4]) -> bool| {
|
||||
(0..400u32).flat_map(|x| (0..400u32).map(move |y| (x, y))).filter(|(x, y)| f(img.pixel(*x, *y))).count()
|
||||
};
|
||||
let red = |p: [u8; 4]| p[0] > 200 && p[1] < 60 && p[2] < 60;
|
||||
let white = |p: [u8; 4]| p[0] > 245 && p[1] > 245 && p[2] > 245;
|
||||
let (plain, coloured) = (draw(None), draw(Some(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)))));
|
||||
assert_eq!(count(&plain, &red), 0, "no red without a text colour");
|
||||
assert!(count(&coloured, &red) > 30, "the labels take the chart's red: {} red pixels", count(&coloured, &red));
|
||||
assert!(count(&plain, &white) > count(&coloured, &white), "the labels were white before");
|
||||
}
|
||||
|
||||
@@ -64,6 +64,25 @@ pub fn is_date_field(kind: &str) -> bool {
|
||||
kind.starts_with("datetime")
|
||||
}
|
||||
|
||||
/// The date and time field types PowerPoint's Header and Footer and Insert ▸ Date & Time offer,
|
||||
/// in the order the dialogs list them. `datetime` and `datetimeFigureOut` are read as `datetime1`
|
||||
/// (see [`field_text`]) but are not offered as a choice.
|
||||
pub const DATE_KINDS: [&str; 13] = [
|
||||
"datetime1",
|
||||
"datetime2",
|
||||
"datetime3",
|
||||
"datetime4",
|
||||
"datetime5",
|
||||
"datetime6",
|
||||
"datetime7",
|
||||
"datetime8",
|
||||
"datetime9",
|
||||
"datetime10",
|
||||
"datetime11",
|
||||
"datetime12",
|
||||
"datetime13",
|
||||
];
|
||||
|
||||
/// The text date/time field `kind` shows at `now` in a run of language `lang` (BCP 47, e.g.
|
||||
/// `en-US`). `None` when `kind` isn't a date/time type this knows, `now` isn't a real date, or
|
||||
/// the format needs names in a language other than English: the field keeps its saved text.
|
||||
|
||||
@@ -214,11 +214,12 @@ impl Backstage {
|
||||
}
|
||||
|
||||
/// Save As and Change File Type: (id for [`SlideApp::export_as`], label, detail).
|
||||
pub const SAVE_TYPES: [(&str, &str, &str); 6] = [
|
||||
pub const SAVE_TYPES: [(&str, &str, &str); 7] = [
|
||||
("pptx", "PowerPoint Presentation (*.pptx)", "Opens in PowerPoint and other presentation apps"),
|
||||
("deckcraft", "DeckCraft Presentation (*.deckcraft)", "DeckCraft's own format: everything is kept"),
|
||||
("pdf", "PDF (*.pdf)", "A fixed copy that keeps its layout on any device"),
|
||||
("outline", "Outline (*.txt)", "The slide titles and bullet text as plain text"),
|
||||
("rtf", "Outline/RTF (*.rtf)", "The slide titles and bullet text for word processors"),
|
||||
("png", "PNG Picture (*.png)", "The current slide as a picture"),
|
||||
("jpeg", "JPEG Picture (*.jpg)", "The current slide as a compressed picture"),
|
||||
];
|
||||
@@ -388,6 +389,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(page_bg)).show(ui, |ui| {
|
||||
// Fields, boxes and buttons sit on the page's own colour: outline them, as PowerPoint does.
|
||||
ui.visuals_mut().text_edit_bg_color = Some(t.field_bg);
|
||||
let w = &mut ui.visuals_mut().widgets;
|
||||
w.inactive.bg_fill = t.field_bg;
|
||||
w.inactive.weak_bg_fill = t.field_bg;
|
||||
|
||||
@@ -561,3 +561,20 @@ fn print_slides_follow_the_settings() {
|
||||
bs.print_layout = "slides";
|
||||
assert_eq!(print_page_size(&bs, &doc), (doc.slide_size.width as f32, doc.slide_size.height as f32), "slides print slide-shaped");
|
||||
}
|
||||
|
||||
/// Options in the File tab opens the Preferences dialog, and Cancel closes it (#782, #752).
|
||||
#[test]
|
||||
fn options_in_backstage_opens_preferences_and_cancel_closes_it() {
|
||||
let mut h = harness(new_app());
|
||||
h.get_by_label("File").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().backstage_open());
|
||||
h.get_by_label("Options").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_some(), "Options opens Preferences dialog");
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("preferences"));
|
||||
h.query_all_by_role_and_label(Role::Button, "Cancel").next().expect("Cancel button").click_accesskit();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_none(), "Cancel closes Preferences dialog");
|
||||
assert!(h.state().backstage_open(), "Backstage remains open");
|
||||
}
|
||||
|
||||
+256
-27
@@ -889,34 +889,35 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) {
|
||||
if let Err(e) = r {
|
||||
app.set_status(e.to_string());
|
||||
}
|
||||
// Cursor.
|
||||
let cursor = match (&app.session.tool.kind, &app.session.tool.hover) {
|
||||
(ToolKind::Shape { .. }, _) => egui::CursorIcon::Crosshair,
|
||||
(ToolKind::TextBox, _) => egui::CursorIcon::Text,
|
||||
(ToolKind::Ink { .. }, _) => egui::CursorIcon::Crosshair,
|
||||
(_, Some(Hit::Resize { handle, .. })) => match handle {
|
||||
0 | 4 => egui::CursorIcon::ResizeNwSe,
|
||||
2 | 6 => egui::CursorIcon::ResizeNeSw,
|
||||
1 | 5 => egui::CursorIcon::ResizeVertical,
|
||||
_ => egui::CursorIcon::ResizeHorizontal,
|
||||
},
|
||||
(_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias,
|
||||
(_, Some(Hit::TableColumn { .. })) => egui::CursorIcon::ResizeColumn,
|
||||
(_, Some(Hit::TableRow { .. })) => egui::CursorIcon::ResizeRow,
|
||||
(_, Some(Hit::Adjust { .. } | Hit::Warp { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand,
|
||||
(_, Some(Hit::Shape { text: true, .. })) => {
|
||||
if app.session.active().is_some_and(|d| d.selection.text.is_some()) {
|
||||
egui::CursorIcon::Text
|
||||
} else {
|
||||
egui::CursorIcon::Move
|
||||
// Cursor. The Format and Animation Painters override everything: their whole point is to
|
||||
// tell the user the tool is armed and waiting for a click (#830).
|
||||
let painter_armed = app.session.painter.is_some() || app.session.anim_painter.is_some();
|
||||
let cursor = if painter_armed {
|
||||
egui::CursorIcon::Copy
|
||||
} else {
|
||||
match (&app.session.tool.kind, &app.session.tool.hover) {
|
||||
(ToolKind::Shape { .. }, _) => egui::CursorIcon::Crosshair,
|
||||
(ToolKind::TextBox, _) => egui::CursorIcon::Text,
|
||||
(ToolKind::Ink { .. }, _) => egui::CursorIcon::Crosshair,
|
||||
(_, Some(Hit::Resize { id, handle })) => resize_cursor(&app.session, *id, *handle),
|
||||
(_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias,
|
||||
(_, Some(Hit::TableColumn { .. })) => egui::CursorIcon::ResizeColumn,
|
||||
(_, Some(Hit::TableRow { .. })) => egui::CursorIcon::ResizeRow,
|
||||
(_, Some(Hit::Adjust { .. } | Hit::Warp { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand,
|
||||
(_, Some(Hit::Shape { text: true, .. })) => {
|
||||
if app.session.active().is_some_and(|d| d.selection.text.is_some()) {
|
||||
egui::CursorIcon::Text
|
||||
} else {
|
||||
egui::CursorIcon::Move
|
||||
}
|
||||
}
|
||||
(_, Some(Hit::Shape { .. } | Hit::Frame { .. })) => egui::CursorIcon::Move,
|
||||
_ => match app.session.tool.guide_drag.map(|g| g.0).or(app.session.tool.guide_hover) {
|
||||
Some(i) if app.session.active().and_then(|d| d.doc.guides.get(i)).is_some_and(|g| g.vertical) => egui::CursorIcon::ResizeHorizontal,
|
||||
Some(_) => egui::CursorIcon::ResizeVertical,
|
||||
None => egui::CursorIcon::Default,
|
||||
},
|
||||
}
|
||||
(_, Some(Hit::Shape { .. } | Hit::Frame { .. })) => egui::CursorIcon::Move,
|
||||
_ => match app.session.tool.guide_drag.map(|g| g.0).or(app.session.tool.guide_hover) {
|
||||
Some(i) if app.session.active().and_then(|d| d.doc.guides.get(i)).is_some_and(|g| g.vertical) => egui::CursorIcon::ResizeHorizontal,
|
||||
Some(_) => egui::CursorIcon::ResizeVertical,
|
||||
None => egui::CursorIcon::Default,
|
||||
},
|
||||
};
|
||||
if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(cursor);
|
||||
@@ -1218,7 +1219,16 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
|
||||
resp.context_menu(|ui| {
|
||||
let editing = app.session.active().and_then(|d| app.session.tool.editing_points(d).and_then(|id| d.shape(id).cloned()));
|
||||
if let Some(sh) = editing {
|
||||
if let Some((path, index)) = app.session.tool.edit_vertex {
|
||||
if let Some((path, index)) = app.session.tool.edit_segment {
|
||||
let cur = deckcraft_engine::cmd::points::segment_kind_of(&sh, path, index);
|
||||
for (l, k) in [("Straight Segment", "straight"), ("Curved Segment", "curved")] {
|
||||
if ui.radio(cur == Some(k), l).clicked() {
|
||||
let _ = app.run("shape.segmentType", json!({"path": path, "segment": index, "type": k}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
} else if let Some((path, index)) = app.session.tool.edit_vertex {
|
||||
let cur = deckcraft_engine::cmd::points::kind_of(&sh, path, index);
|
||||
for (l, k) in [("Smooth Point", "smooth"), ("Straight Point", "straight"), ("Corner Point", "corner")] {
|
||||
if ui.radio(cur == Some(k), l).clicked() {
|
||||
@@ -1231,6 +1241,16 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
|
||||
app.session.tool.edit_vertex = None;
|
||||
ui.close();
|
||||
}
|
||||
let (label, id) = match deckcraft_engine::cmd::points::closed_at(&sh, path, index) {
|
||||
Some(false) => ("Close Path", "shape.closePath"),
|
||||
_ => ("Open Path", "shape.openPath"),
|
||||
};
|
||||
if ui.button(label).clicked() {
|
||||
let _ = app.run(id, json!({"path": path, "index": index}));
|
||||
// The points are numbered from the new start now.
|
||||
app.session.tool.edit_vertex = None;
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
}
|
||||
if ui.button("Exit Edit Points").clicked() {
|
||||
@@ -1483,17 +1503,157 @@ fn grid_fits(span: f32, step: f32) -> bool {
|
||||
span.is_finite() && span > 0.0 && step >= 1.0 && span / step <= 800.0
|
||||
}
|
||||
|
||||
/// The resize cursor for `handle` of shape `id`: the handle's resize axis is computed in the
|
||||
/// shape's own space and mapped through its transform, so a rotated or flipped shape shows the
|
||||
/// diagonal that matches where the drag actually goes (#745). Without a transform the mapping
|
||||
/// reduces to the classic NW/NE/E/SE/S/SW/W assignment.
|
||||
fn resize_cursor(session: &deckcraft_engine::Session, id: deckcraft_model::ShapeId, handle: u8) -> egui::CursorIcon {
|
||||
// The resize handles are drawn from the shape's effective box (a placeholder inherits it
|
||||
// from its layout), so the cursor follows the same one.
|
||||
let Some(d) = session.active() else { return egui::CursorIcon::Default };
|
||||
let Some(sh) = d.shape(id) else { return egui::CursorIcon::Default };
|
||||
let x = deckcraft_engine::cmd::xfrm_of(&d.doc, &d.selection, sh);
|
||||
// The resize axis in the shape's own coordinates, as (dx, dy) from the box centre toward
|
||||
// the handle. Corners resize along the diagonal, edge handles along their edge. The
|
||||
// direction is normalised - the diagonal at 45 degrees whatever the shape's proportions -
|
||||
// so a wide rectangle still shows a diagonal cursor on its corners.
|
||||
let (dx, dy): (f64, f64) = match handle {
|
||||
0 => (-1.0, -1.0),
|
||||
1 => (0.0, -1.0),
|
||||
2 => (1.0, -1.0),
|
||||
3 => (1.0, 0.0),
|
||||
4 => (1.0, 1.0),
|
||||
5 => (0.0, 1.0),
|
||||
6 => (-1.0, 1.0),
|
||||
_ => (-1.0, 0.0),
|
||||
};
|
||||
// Map through the linear part of the transform (rotation, flips): two points along the
|
||||
// axis, the difference is the slide-space direction.
|
||||
let centre = Point::new(x.x + x.w / 2.0, x.y + x.h / 2.0);
|
||||
let a = x.affine();
|
||||
let p0 = a * centre;
|
||||
let p1 = a * Point::new(centre.x + dx, centre.y + dy);
|
||||
let v = p1 - p0;
|
||||
if !v.x.is_finite() || !v.y.is_finite() || (v.x.abs() < 1e-12 && v.y.abs() < 1e-12) {
|
||||
return egui::CursorIcon::Default;
|
||||
}
|
||||
// Angle in [0, 180): 0° = right (horizontal), 45° = down-right (NW-SE), 90° = down
|
||||
// (vertical), 135° = down-left (NE-SW). Pick the closest of the four.
|
||||
let mut deg = v.y.atan2(v.x).to_degrees();
|
||||
if deg < 0.0 {
|
||||
deg += 180.0;
|
||||
}
|
||||
if (22.5..67.5).contains(°) {
|
||||
egui::CursorIcon::ResizeNwSe
|
||||
} else if (67.5..112.5).contains(°) {
|
||||
egui::CursorIcon::ResizeVertical
|
||||
} else if (112.5..157.5).contains(°) {
|
||||
egui::CursorIcon::ResizeNeSw
|
||||
} else {
|
||||
egui::CursorIcon::ResizeHorizontal
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui::{Event, Modifiers, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
use super::*;
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
/// #745: the resize cursor follows the handle's on-screen diagonal. A 45°-rotated shape's
|
||||
/// NW corner sits at the top-right of the screen, where the drag axis is NE-SW — the old
|
||||
/// fixed handle-to-cursor mapping showed NW-SE there.
|
||||
#[test]
|
||||
fn resize_cursor_tracks_rotation() {
|
||||
let mut s = Session::with_new();
|
||||
// A square shape: the NW handle's diagonal is at 45° unrotated, so each 45° of shape
|
||||
// rotation cleanly moves the cursor to the next of the four resize directions.
|
||||
let id = s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [100.0, 100.0, 100.0, 100.0]})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap() as u32;
|
||||
let sid = deckcraft_model::ShapeId(id);
|
||||
// Unrotated: the NW handle (0) drags the NW-SE diagonal.
|
||||
assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNwSe);
|
||||
assert_eq!(resize_cursor(&s, sid, 4), egui::CursorIcon::ResizeNwSe);
|
||||
assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNeSw);
|
||||
assert_eq!(resize_cursor(&s, sid, 6), egui::CursorIcon::ResizeNeSw);
|
||||
assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeVertical);
|
||||
assert_eq!(resize_cursor(&s, sid, 3), egui::CursorIcon::ResizeHorizontal);
|
||||
// 45° rotation: the NW corner's drag axis rotates 45° with the shape — it is now vertical.
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let pres = Arc::make_mut(&mut doc.doc);
|
||||
let slide = Arc::make_mut(&mut pres.slides[0]);
|
||||
if let Some(sh) = slide.shapes.iter_mut().find(|x| x.id == sid)
|
||||
&& let Some(x) = sh.xfrm.as_mut()
|
||||
{
|
||||
x.rot = 45.0;
|
||||
}
|
||||
}
|
||||
assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeVertical, "45°: NW handle drags vertically");
|
||||
assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeHorizontal, "45°: NE handle drags horizontally");
|
||||
// 90° rotation: the NW corner is at the top-right; the drag axis is NE-SW.
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let pres = Arc::make_mut(&mut doc.doc);
|
||||
let slide = Arc::make_mut(&mut pres.slides[0]);
|
||||
if let Some(sh) = slide.shapes.iter_mut().find(|x| x.id == sid)
|
||||
&& let Some(x) = sh.xfrm.as_mut()
|
||||
{
|
||||
x.rot = 90.0;
|
||||
}
|
||||
}
|
||||
assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNeSw, "90°: the diagonal is NE-SW");
|
||||
assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNwSe, "90°: NE diagonal is NW-SE");
|
||||
assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeHorizontal, "90°: the top edge is horizontal");
|
||||
}
|
||||
|
||||
/// #745: a corner handle shows a diagonal cursor whatever the shape's proportions. The
|
||||
/// corner's direction is the diagonal, not the line to the box's centre, so a wide
|
||||
/// rectangle's NW corner still drags along the NW-SE diagonal.
|
||||
#[test]
|
||||
fn a_wide_shape_keeps_its_corner_cursors_diagonal() {
|
||||
let mut s = Session::with_new();
|
||||
let id = s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [100.0, 100.0, 400.0, 80.0]})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap() as u32;
|
||||
let sid = deckcraft_model::ShapeId(id);
|
||||
assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNwSe, "the NW corner drags NW-SE");
|
||||
assert_eq!(resize_cursor(&s, sid, 4), egui::CursorIcon::ResizeNwSe, "the SE corner drags NW-SE");
|
||||
assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNeSw, "the NE corner drags NE-SW");
|
||||
assert_eq!(resize_cursor(&s, sid, 6), egui::CursorIcon::ResizeNeSw, "the SW corner drags NE-SW");
|
||||
// The edges keep their own cursors.
|
||||
assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeVertical, "the top edge drags vertically");
|
||||
assert_eq!(resize_cursor(&s, sid, 3), egui::CursorIcon::ResizeHorizontal, "the right edge drags horizontally");
|
||||
}
|
||||
|
||||
/// #745: a placeholder that inherits its box from the layout still shows a resize cursor.
|
||||
/// Its handles are drawn from the inherited box, so the cursor follows that one.
|
||||
#[test]
|
||||
fn a_placeholder_that_inherits_its_box_has_a_resize_cursor() {
|
||||
let s = Session::with_new();
|
||||
let st = s.doc().unwrap();
|
||||
let ph = st
|
||||
.current_slide()
|
||||
.unwrap()
|
||||
.shapes
|
||||
.iter()
|
||||
.find(|sh| sh.xfrm.is_none() && sh.ph.is_some())
|
||||
.expect("the new slide has a placeholder without a box of its own")
|
||||
.id;
|
||||
assert_eq!(resize_cursor(&s, ph, 0), egui::CursorIcon::ResizeNwSe, "the NW corner drags NW-SE");
|
||||
assert_eq!(resize_cursor(&s, ph, 2), egui::CursorIcon::ResizeNeSw, "the NE corner drags NE-SW");
|
||||
assert_eq!(resize_cursor(&s, ph, 1), egui::CursorIcon::ResizeVertical, "the top edge drags vertically");
|
||||
}
|
||||
|
||||
fn harness() -> Harness<'static, SlideApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
@@ -1843,6 +2003,44 @@ mod tests {
|
||||
assert!((t.cols[0] - 240.0).abs() < 0.5 && (t.cols[1] - 160.0).abs() < 0.5, "{:?}", t.cols);
|
||||
}
|
||||
|
||||
fn right_click(h: &mut Harness<'_, SlideApp>, pos: Pos2) {
|
||||
h.event(Event::PointerMoved(pos));
|
||||
h.step();
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// In Edit Points, right-clicking a segment offers Straight/Curved Segment and right-clicking
|
||||
/// a point offers Open Path, then Close Path once the outline is open.
|
||||
#[test]
|
||||
fn edit_points_menu_sets_segment_types_and_opens_the_path() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("slide.layout", json!({"layout": "blank"})).unwrap();
|
||||
h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [200, 150, 300, 200]})).unwrap();
|
||||
h.state_mut().run("shape.editPoints", json!({"on": true})).unwrap();
|
||||
h.run_steps(3);
|
||||
let q = |h: &mut Harness<'_, SlideApp>| h.state_mut().session.execute("shape.points", &json!({})).unwrap();
|
||||
// The middle of the right edge: the segment ending at point 2.
|
||||
let p = screen(h.state(), 500.0, 250.0);
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("Curved Segment").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["segments"][0], json!(["straight", "straight", "curved", "straight"]));
|
||||
// The bottom-right corner: Open Path breaks the outline there.
|
||||
let p = screen(h.state(), 500.0, 350.0);
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("Open Path").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(5));
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("Close Path").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_step_follows_spacing_and_rejects_unusable_values() {
|
||||
let step = super::grid_line_step(72.0, 0.5).unwrap_or(0.0);
|
||||
@@ -1854,6 +2052,37 @@ mod tests {
|
||||
assert!(super::grid_line_step(6.0, 0.0).is_none());
|
||||
assert!(super::grid_line_step(6.0, 0.1).is_none(), "a 6 pt grid at 10% zoom is finer than one screen point");
|
||||
}
|
||||
|
||||
/// #830: when the Format or Animation Painter is armed, the cursor shows the copy icon so the
|
||||
/// user can see the tool is waiting for a click.
|
||||
#[test]
|
||||
fn format_painter_sets_the_cursor() {
|
||||
let mut h = harness();
|
||||
// Hover over the canvas so the pointer function runs and sets the cursor.
|
||||
h.hover_at(egui::pos2(600.0, 400.0));
|
||||
h.run_steps(2);
|
||||
let before = h.output().platform_output.cursor_icon;
|
||||
assert_ne!(before, egui::CursorIcon::Copy, "no painter is armed yet");
|
||||
// Arming the format painter overrides the cursor to Copy (#830).
|
||||
h.state_mut().session.painter = Some((Default::default(), false));
|
||||
h.run_steps(3);
|
||||
let after = h.output().platform_output.cursor_icon;
|
||||
assert_eq!(after, egui::CursorIcon::Copy, "the format painter arms the Copy cursor");
|
||||
}
|
||||
|
||||
/// #830: the Animation Painter arms the same cursor.
|
||||
#[test]
|
||||
fn animation_painter_sets_the_cursor() {
|
||||
let mut h = harness();
|
||||
h.hover_at(egui::pos2(600.0, 400.0));
|
||||
h.run_steps(2);
|
||||
let before = h.output().platform_output.cursor_icon;
|
||||
assert_ne!(before, egui::CursorIcon::Copy, "no painter is armed yet");
|
||||
h.state_mut().session.anim_painter = Some((vec![], false));
|
||||
h.run_steps(3);
|
||||
let after = h.output().platform_output.cursor_icon;
|
||||
assert_eq!(after, egui::CursorIcon::Copy, "the animation painter arms the Copy cursor");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -50,6 +50,18 @@ fn pane_opens_lists_changes_and_accept_all_applies_them() {
|
||||
assert!(h.state().session.active().unwrap().compare.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_all_empties_the_pane_and_keeps_the_deck() {
|
||||
let (mut h, n) = harness();
|
||||
let doc = h.state().session.active().unwrap().doc.clone();
|
||||
h.get_by_label_contains("Reject All").click();
|
||||
h.run_steps(3);
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &st.doc), "nothing applied");
|
||||
assert_eq!(st.doc.slides.len(), n);
|
||||
assert!(h.query_by_label("No differences left.").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "writes a screenshot for review"]
|
||||
fn screenshot() {
|
||||
|
||||
+151
-22
@@ -42,6 +42,7 @@ fn title(id: &str) -> &'static str {
|
||||
"altText" => "Alt Text",
|
||||
"outline" => "Slides from Outline",
|
||||
"renameSection" => "Rename Section",
|
||||
"reuseSlides" => "Reuse Slides",
|
||||
"renameShape" => "Rename",
|
||||
"renameLayout" => "Rename Layout",
|
||||
"paragraph" => "Paragraph",
|
||||
@@ -1091,6 +1092,25 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let hf = app.session.active().map(|s| s.doc.header_footer.clone()).unwrap_or_default();
|
||||
let date = check(ui, d, "Date and time", "date", hf.date);
|
||||
field(ui, d, " Fixed date (empty = update automatically):", "dateText", &hf.date_text);
|
||||
// The format applies to an automatic date; a fixed one is the text as typed.
|
||||
let automatic = d.get("dateText", &hf.date_text).is_empty();
|
||||
let fmt_now = if hf.date_format.is_empty() { "datetime1".to_string() } else { hf.date_format.clone() };
|
||||
// Not `d.get`: that stores the default as if the user had picked it.
|
||||
let fmt = d.fields.get("dateFormat").cloned().unwrap_or_else(|| fmt_now.clone());
|
||||
let rows = date_rows("en-US");
|
||||
let label = rows.iter().find(|(k, _)| *k == fmt).map(|(_, s)| s.clone()).unwrap_or_else(|| fmt.clone());
|
||||
ui.add_enabled_ui(date && automatic, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(" Format:");
|
||||
egui::ComboBox::from_id_salt("hf-date-format").selected_text(label).width(200.0).show_ui(ui, |ui| {
|
||||
for (kind, sample) in &rows {
|
||||
if ui.selectable_label(fmt == *kind, sample).clicked() {
|
||||
d.fields.insert("dateFormat".into(), (*kind).to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
let num_on = check(ui, d, "Slide number", "num", hf.slide_number);
|
||||
let foot = check(ui, d, "Footer", "footer", hf.footer);
|
||||
field(ui, d, " Footer text:", "footerText", &hf.footer_text);
|
||||
@@ -1108,11 +1128,20 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
if apply || apply_all {
|
||||
let dt = d.get("dateText", "");
|
||||
let ft = d.get("footerText", "");
|
||||
run(
|
||||
app,
|
||||
"design.headerFooter",
|
||||
json!({"date": date, "dateText": dt, "slideNumber": num_on, "footer": foot, "footerText": ft, "hideOnTitle": hide, "all": apply_all}),
|
||||
);
|
||||
let mut params = serde_json::Map::new();
|
||||
params.insert("date".into(), json!(date));
|
||||
params.insert("dateText".into(), json!(dt));
|
||||
// Only a format the user picked: sending the default would rewrite every slide's
|
||||
// existing date field (an imported datetime4, say) to datetime1.
|
||||
if d.fields.contains_key("dateFormat") {
|
||||
params.insert("dateFormat".into(), json!(d.get("dateFormat", &fmt_now)));
|
||||
}
|
||||
params.insert("slideNumber".into(), json!(num_on));
|
||||
params.insert("footer".into(), json!(foot));
|
||||
params.insert("footerText".into(), json!(ft));
|
||||
params.insert("hideOnTitle".into(), json!(hide));
|
||||
params.insert("all".into(), json!(apply_all));
|
||||
run(app, "design.headerFooter", serde_json::Value::Object(params));
|
||||
}
|
||||
apply || apply_all || d.fields.contains_key("cancel")
|
||||
}
|
||||
@@ -1537,6 +1566,66 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
"reuseSlides" => {
|
||||
let path = d.params.get("path").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let slides: Vec<(usize, String)> = d
|
||||
.params
|
||||
.get("slides")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|x| {
|
||||
Some((usize::try_from(x.get("index")?.as_u64()?).ok()?, x.get("title").and_then(Value::as_str).unwrap_or("").to_string()))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone());
|
||||
ui.label(format!("Insert slides from {name} after the current slide:"));
|
||||
// Unticked slides are remembered by index; everything starts ticked.
|
||||
let off = |d: &Dialog, i: usize| d.fields.get(&format!("off{i}")).is_some_and(|v| v == "1");
|
||||
ui.horizontal(|ui| {
|
||||
if ui.small_button("Select All").clicked() {
|
||||
for (i, _) in &slides {
|
||||
d.fields.remove(&format!("off{i}"));
|
||||
}
|
||||
}
|
||||
if ui.small_button("Select None").clicked() {
|
||||
for (i, _) in &slides {
|
||||
d.fields.insert(format!("off{i}"), "1".into());
|
||||
}
|
||||
}
|
||||
});
|
||||
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
|
||||
for (i, title) in &slides {
|
||||
let mut on = !off(d, *i);
|
||||
let label = if title.trim().is_empty() { format!("{} (no title)", i + 1) } else { format!("{} {}", i + 1, title.trim()) };
|
||||
if ui.checkbox(&mut on, label).changed() {
|
||||
if on {
|
||||
d.fields.remove(&format!("off{i}"));
|
||||
} else {
|
||||
d.fields.insert(format!("off{i}"), "1".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut keep = d.params.get("keep").and_then(Value::as_bool).unwrap_or(false);
|
||||
if ui.checkbox(&mut keep, "Keep source formatting").changed()
|
||||
&& let Some(o) = d.params.as_object_mut()
|
||||
{
|
||||
o.insert("keep".into(), json!(keep));
|
||||
}
|
||||
let picked: Vec<usize> = slides.iter().map(|(i, _)| *i).filter(|i| !off(d, *i)).collect();
|
||||
let (ok, cancel) = buttons(ui, "Insert");
|
||||
if ok {
|
||||
if picked.is_empty() {
|
||||
app.set_status("Tick at least one slide to insert".to_string());
|
||||
return false;
|
||||
}
|
||||
app.reuse_slides(&path, keep, &picked);
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
"renameSection" | "renameShape" | "renameLayout" => {
|
||||
let cur = d.params.get("name").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
field(ui, d, "Name:", "name", &cur);
|
||||
@@ -1565,6 +1654,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
("PowerPoint Presentation (.pptx)", "pptx"),
|
||||
("Outline (.txt)", "outline"),
|
||||
("Animated GIF (.gif)", "gif"),
|
||||
("Outline/RTF (.rtf)", "rtf"),
|
||||
] {
|
||||
if ui.radio(fmt == f, l).clicked() {
|
||||
d.fields.insert("fmt".into(), f.into());
|
||||
@@ -2110,8 +2200,8 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
_ => about(app, ui),
|
||||
}
|
||||
let (ok, _) = buttons(ui, "Close");
|
||||
ok
|
||||
let (ok, cancel) = buttons(ui, "Close");
|
||||
ok || cancel
|
||||
}
|
||||
"autocorrect" => {
|
||||
let pr = &mut app.session.prefs;
|
||||
@@ -2466,21 +2556,7 @@ const DATE_LANGS: &[(&str, &str)] = &[
|
||||
("sv-SE", "Swedish"),
|
||||
];
|
||||
|
||||
const DATE_KINDS: [&str; 13] = [
|
||||
"datetime1",
|
||||
"datetime2",
|
||||
"datetime3",
|
||||
"datetime4",
|
||||
"datetime5",
|
||||
"datetime6",
|
||||
"datetime7",
|
||||
"datetime8",
|
||||
"datetime9",
|
||||
"datetime10",
|
||||
"datetime11",
|
||||
"datetime12",
|
||||
"datetime13",
|
||||
];
|
||||
use deckcraft_text::datetime::DATE_KINDS;
|
||||
|
||||
/// Samples `field_text` can compute for `lang`. Named months and AM/PM exist for English only;
|
||||
/// other languages keep the numeric date and the 24-hour times.
|
||||
@@ -3406,6 +3482,55 @@ mod tests {
|
||||
h
|
||||
}
|
||||
|
||||
/// Header & Footer ▸ Apply without picking a format leaves a slide's existing date field in
|
||||
/// its own format instead of rewriting it to the default one.
|
||||
#[test]
|
||||
fn header_footer_apply_keeps_an_existing_date_format() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::vec2;
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::SlideApp;
|
||||
let mut session = deckcraft_engine::Session::with_new();
|
||||
session.execute("design.headerFooter", &json!({"date": true, "dateText": "", "dateFormat": "datetime4", "all": true})).unwrap();
|
||||
// A file whose document-level format is unset, as an imported deck's is.
|
||||
Arc::make_mut(&mut session.doc_mut().unwrap().doc).header_footer.date_format = String::new();
|
||||
let date_fields = |app: &SlideApp| -> Vec<String> {
|
||||
app.session
|
||||
.doc()
|
||||
.unwrap()
|
||||
.doc
|
||||
.slides
|
||||
.iter()
|
||||
.flat_map(|sl| sl.shapes.iter())
|
||||
.filter(|sh| sh.ph_type() == Some(deckcraft_model::PhType::Date))
|
||||
.flat_map(|sh| sh.text.iter())
|
||||
.flat_map(|t| t.paragraphs.iter())
|
||||
.flat_map(|p| p.runs.iter())
|
||||
.filter_map(|r| match &r.kind {
|
||||
deckcraft_model::text::RunKind::Field { field } => Some(field.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(session, crate::Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
assert_eq!(date_fields(harness.state()), vec!["datetime4".to_string()]);
|
||||
harness.state_mut().run("app.dialog", json!({"id": "headerFooter"})).unwrap();
|
||||
harness.run_steps(2);
|
||||
harness.get_by_label("Apply").click_accesskit();
|
||||
harness.run_steps(3);
|
||||
assert_eq!(date_fields(harness.state()), vec!["datetime4".to_string()], "the dialog left the field's format alone");
|
||||
}
|
||||
|
||||
/// The Export… dialog (Animated GIF) shows the GIF options and exports through `file.export` (#171).
|
||||
#[test]
|
||||
fn export_dialog_writes_an_animated_gif() {
|
||||
@@ -3762,3 +3887,7 @@ mod about_credits_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "preferences_tests.rs"]
|
||||
mod preferences_tests;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Text fields must show where they are: in the light theme the field was as white as the dialog,
|
||||
//! pane or ribbon it sits on and had no outline, so only its label (or hint) was visible (#1204).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui::accesskit::Role;
|
||||
use egui::epaint::Shape;
|
||||
use egui::{Color32, Rect, Stroke};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp, theme};
|
||||
|
||||
fn harness(app: SlideApp) -> Harness<'static, SlideApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
// The app keeps asking for frames, so step instead of run().
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
/// The largest per-channel difference between two colours.
|
||||
fn distance(a: Color32, b: Color32) -> u8 {
|
||||
a.r().abs_diff(b.r()).max(a.g().abs_diff(b.g())).max(a.b().abs_diff(b.b()))
|
||||
}
|
||||
|
||||
/// The frame egui painted for the text field at `field` (its fill and outline).
|
||||
fn frame_at(h: &Harness<'_, SlideApp>, field: Rect) -> (Color32, Stroke) {
|
||||
h.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|c| match &c.shape {
|
||||
Shape::Rect(r) if r.rect.min.distance(field.min) < 2.0 && r.rect.max.distance(field.max) < 2.0 => Some((r.fill, r.stroke)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no frame painted for the field at {field:?}"))
|
||||
}
|
||||
|
||||
/// What `c` shows when painted over the opaque `surface`. `Color32` is premultiplied, so each
|
||||
/// channel is `c + surface * (255 - alpha) / 255`.
|
||||
fn over(c: Color32, surface: Color32) -> Color32 {
|
||||
let behind = |s: u8| (u16::from(s) * u16::from(255 - c.a()) / 255) as u8;
|
||||
Color32::from_rgb(c.r().saturating_add(behind(surface.r())), c.g().saturating_add(behind(surface.g())), c.b().saturating_add(behind(surface.b())))
|
||||
}
|
||||
|
||||
/// A frame with this `fill` and `stroke` stands apart from the opaque `surface`: it paints a fill
|
||||
/// or an outline (at least 1 px wide) that, as actually shown over the surface, differs from it.
|
||||
fn stands_apart(fill: Color32, stroke: Stroke, surface: Color32) -> bool {
|
||||
let outlined = stroke.width >= 1.0 && distance(over(stroke.color, surface), surface) >= 24;
|
||||
outlined || distance(over(fill, surface), surface) >= 16
|
||||
}
|
||||
|
||||
/// The field stands apart from `surface` by its fill or by its outline.
|
||||
fn assert_visible(h: &Harness<'_, SlideApp>, what: &str, surface: Color32) {
|
||||
let fields: Vec<Rect> = h.query_all_by_role(Role::TextInput).map(|n| n.rect()).collect();
|
||||
assert!(!fields.is_empty(), "{what}: no text field");
|
||||
for field in fields {
|
||||
let (fill, stroke) = frame_at(h, field);
|
||||
assert!(
|
||||
stands_apart(fill, stroke, surface),
|
||||
"{what}: the field at {field:?} (fill {fill:?}, outline {stroke:?}) can't be told from the {surface:?} behind it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_painted_fill_or_outline_stands_apart() {
|
||||
let surface = Color32::WHITE;
|
||||
// Stroke::NONE is transparent black: nothing is painted, whatever its RGB.
|
||||
assert!(!stands_apart(surface, Stroke::NONE, surface));
|
||||
assert!(!stands_apart(Color32::WHITE, Stroke::NONE, Color32::from_rgb(250, 250, 250)));
|
||||
// A fully transparent fill shows the surface.
|
||||
assert!(!stands_apart(Color32::TRANSPARENT, Stroke::NONE, surface));
|
||||
assert!(!stands_apart(Color32::TRANSPARENT, Stroke::new(0.5, Color32::BLACK), surface));
|
||||
// A transparent outline paints nothing either.
|
||||
assert!(!stands_apart(surface, Stroke::new(1.0, Color32::TRANSPARENT), surface));
|
||||
// A real 1 px dark outline, or a clearly different opaque fill, does.
|
||||
assert!(stands_apart(surface, Stroke::new(1.0, Color32::from_gray(120)), surface));
|
||||
assert!(stands_apart(Color32::from_gray(200), Stroke::NONE, surface));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_text_fields_show_in_dialogs_panes_and_the_ribbon() {
|
||||
let light = theme::Tokens::light();
|
||||
|
||||
// Home › Find: "Find what:" in the dialog card.
|
||||
let mut h = harness(SlideApp::new(Session::with_new(), Services::default()));
|
||||
h.state_mut().run("app.find", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_visible(&h, "Find and Replace", h.ctx.global_style().visuals.window_fill);
|
||||
// Still visible with the keyboard in it.
|
||||
let field = h.get_by_role(Role::TextInput).rect();
|
||||
h.get_by_role(Role::TextInput).click();
|
||||
h.run_steps(2);
|
||||
assert!(h.ctx.memory(|m| m.focused().is_some()), "the click puts the keyboard in the field");
|
||||
let (fill, stroke) = frame_at(&h, field);
|
||||
// Same surface as the dialog check above: the field sits on the dialog's window fill.
|
||||
let surface = h.ctx.global_style().visuals.window_fill;
|
||||
assert!(stands_apart(fill, stroke, surface), "focused field: fill {fill:?}, outline {stroke:?}");
|
||||
|
||||
// Review › Thesaurus: the search box on the task pane.
|
||||
let mut h = harness(SlideApp::new(Session::with_new(), Services::default()));
|
||||
h.state_mut().ui.pane = Some("thesaurus".into());
|
||||
h.run_steps(3);
|
||||
assert_visible(&h, "Thesaurus pane", light.card);
|
||||
|
||||
// Transitions › Duration: the spinner on the ribbon.
|
||||
let mut app = SlideApp::new(Session::with_new(), Services::default());
|
||||
app.ui.tab = "Transitions".into();
|
||||
let h = harness(app);
|
||||
assert_visible(&h, "Transitions ribbon", light.card);
|
||||
}
|
||||
+107
-5
@@ -154,7 +154,7 @@ fn open_command(name: &str) -> Option<&'static str> {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if is_presentation_name(&lower) {
|
||||
Some("file.openBytes")
|
||||
} else if lower.ends_with(".txt") || lower.ends_with(".md") {
|
||||
} else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".rtf") {
|
||||
Some("slide.fromOutline")
|
||||
} else {
|
||||
None
|
||||
@@ -175,7 +175,7 @@ fn infer_command(name: &str, bytes: &[u8]) -> Option<&'static str> {
|
||||
Some("insert.audio")
|
||||
} else if ct.starts_with("video/") {
|
||||
Some("insert.video")
|
||||
} else if lower.ends_with(".txt") || lower.ends_with(".md") {
|
||||
} else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".rtf") {
|
||||
Some("slide.fromOutline")
|
||||
} else {
|
||||
None
|
||||
@@ -244,6 +244,10 @@ pub struct UiState {
|
||||
pub notes: bool,
|
||||
pub notes_height: f32,
|
||||
pub thumbs_width: f32,
|
||||
/// Vertical scroll offset of the thumbnail pane, recorded each frame for the control
|
||||
/// channel and tests; not persisted.
|
||||
#[serde(skip)]
|
||||
pub thumbs_scroll: f32,
|
||||
/// Open task pane: `format`, `animation`, `selection`, `comments`, `designer`, `background`,
|
||||
/// `smartart` (Text Pane).
|
||||
pub pane: Option<String>,
|
||||
@@ -286,6 +290,7 @@ impl Default for UiState {
|
||||
notes: true,
|
||||
notes_height: 52.0,
|
||||
thumbs_width: 236.0,
|
||||
thumbs_scroll: 0.0,
|
||||
pane: None,
|
||||
pane_width: 300.0,
|
||||
ruler: false,
|
||||
@@ -752,6 +757,7 @@ impl SlideApp {
|
||||
"jpeg" | "jpg" => ("jpg", "jpeg", false),
|
||||
"pptx" => ("pptx", "pptx", false),
|
||||
"outline" | "txt" => ("txt", "outline", false),
|
||||
"rtf" => ("rtf", "rtf", false),
|
||||
"deckcraft" => ("deckcraft", "deckcraft", false),
|
||||
"gif" => ("gif", "gif", false),
|
||||
other => {
|
||||
@@ -1264,7 +1270,20 @@ impl SlideApp {
|
||||
return;
|
||||
};
|
||||
let Some(path) = pick("open") else { return };
|
||||
match self.session.execute("slide.reuse", &json!({"path": path, "keepFormatting": keep})) {
|
||||
// Offer the file's slides to choose from; all of them are ticked to begin with.
|
||||
match self.session.execute("slide.reuseList", &json!({"path": path})) {
|
||||
Ok(list) => {
|
||||
let mut d = dialogs::Dialog::new("reuseSlides");
|
||||
d.params = json!({"path": path, "keep": keep, "slides": list["slides"]});
|
||||
self.dialog = Some(d);
|
||||
}
|
||||
Err(e) => self.set_status(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert the slides `picked` (indices) of the presentation at `path` after the current slide.
|
||||
pub fn reuse_slides(&mut self, path: &str, keep: bool, picked: &[usize]) {
|
||||
match self.session.execute("slide.reuse", &json!({"path": path, "keepFormatting": keep, "slides": picked})) {
|
||||
Ok(r) => self.set_status(format!("Inserted {} slides from {path}", r["slides"])),
|
||||
Err(e) => self.set_status(e.to_string()),
|
||||
}
|
||||
@@ -1474,7 +1493,7 @@ impl SlideApp {
|
||||
}
|
||||
};
|
||||
let params = if cmd == "slide.fromOutline" {
|
||||
json!({"text": String::from_utf8_lossy(bytes), "markdown": name.to_ascii_lowercase().ends_with(".md")})
|
||||
json!({"text": deckcraft_engine::cmd::file::outline_text(bytes), "markdown": name.to_ascii_lowercase().ends_with(".md")})
|
||||
} else {
|
||||
json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(bytes)})
|
||||
};
|
||||
@@ -1714,7 +1733,20 @@ impl SlideApp {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if ctx.memory(|m| m.focused().is_some()) || self.dialog.as_ref().is_some_and(|d| d.modal()) || self.palette.is_some() {
|
||||
let modal = self.dialog.as_ref().is_some_and(|d| d.modal()) || self.palette.is_some();
|
||||
// While slide text is being edited, the text owns the keyboard (#93). Tab and the arrow
|
||||
// keys move egui's keyboard focus onto ordinary widgets (the window's drag handle, ribbon
|
||||
// buttons), and that stray focus would swallow every later key and end text entry. Let it
|
||||
// go; a real text field or an open menu keeps its keys.
|
||||
if !modal
|
||||
&& self.session.active().is_some_and(|d| d.selection.text.is_some())
|
||||
&& !ctx.text_edit_focused()
|
||||
&& !ctx.any_popup_open()
|
||||
&& let Some(id) = ctx.memory(|m| m.focused())
|
||||
{
|
||||
ctx.memory_mut(|m| m.surrender_focus(id));
|
||||
}
|
||||
if ctx.memory(|m| m.focused().is_some()) || modal {
|
||||
return;
|
||||
}
|
||||
if self.backstage.is_some() {
|
||||
@@ -2084,6 +2116,8 @@ mod compare_tests;
|
||||
#[cfg(test)]
|
||||
mod eyedropper_tests;
|
||||
#[cfg(test)]
|
||||
mod field_frame_tests;
|
||||
#[cfg(test)]
|
||||
mod find_dialog_tests;
|
||||
#[cfg(test)]
|
||||
mod huge_deck_tests;
|
||||
@@ -2555,6 +2589,39 @@ mod tests {
|
||||
assert_eq!(level(&harness), 1);
|
||||
}
|
||||
|
||||
/// #93: Tab in an edited list indented the item but handed the keyboard to egui's focus
|
||||
/// cycle: the stray focus (e.g. on the window's drag handle) swallowed every later key, so the
|
||||
/// list could neither be typed into nor continued with Enter.
|
||||
#[test]
|
||||
fn tab_in_an_edited_list_keeps_the_keyboard() {
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
harness.event(egui::Event::Text("one".into()));
|
||||
harness.run_steps(2);
|
||||
harness.key_press(egui::Key::Enter);
|
||||
harness.run_steps(2);
|
||||
harness.key_press(egui::Key::Tab); // Increase List Level
|
||||
harness.run_steps(2);
|
||||
harness.event(egui::Event::Text("sub".into()));
|
||||
harness.run_steps(2);
|
||||
harness.key_press(egui::Key::Enter); // continues the list
|
||||
harness.run_steps(2);
|
||||
harness.event(egui::Event::Text("two".into()));
|
||||
harness.run_steps(2);
|
||||
assert_eq!(title_text(harness.state()), "one\nsub\ntwo", "typing after Tab must reach the text");
|
||||
let body = harness.state().session.active().unwrap().doc.slides[0].shapes[0].text.clone().unwrap();
|
||||
let levels: Vec<u8> = body.paragraphs.iter().map(|p| p.level).collect();
|
||||
assert_eq!(levels, vec![0, 1, 1], "Tab indents one level; Enter keeps the level");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_needs_an_open_presentation() {
|
||||
let mut app = SlideApp::new(Session::new(), Services::default());
|
||||
@@ -2735,6 +2802,41 @@ mod tests {
|
||||
assert_ne!(s.session.prefs.snap_to_grid, before.snap_to_grid, "Done keeps snap to grid");
|
||||
}
|
||||
|
||||
/// About dialog: Close and Cancel each close the dialog.
|
||||
#[test]
|
||||
fn about_dialog_close_and_cancel_close_it() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
harness.state_mut().run("app.about", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_some());
|
||||
harness
|
||||
.query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel")
|
||||
.next()
|
||||
.unwrap_or_else(|| panic!("no Cancel button in About dialog"))
|
||||
.click_accesskit();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_none(), "Cancel closes About dialog");
|
||||
|
||||
harness.state_mut().run("app.about", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_some());
|
||||
harness
|
||||
.query_all_by_role_and_label(egui::accesskit::Role::Button, "Close")
|
||||
.next()
|
||||
.unwrap_or_else(|| panic!("no Close button in About dialog"))
|
||||
.click_accesskit();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_none(), "Close closes About dialog");
|
||||
}
|
||||
|
||||
fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> {
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
|
||||
@@ -1189,7 +1189,8 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let _ = paint_anim_tile;
|
||||
}
|
||||
|
||||
/// Review › Compare: the revised copy's slide-level changes; click to go there, Accept to take it.
|
||||
/// Review › Compare: the revised copy's slide-level changes; click to go there, Accept to take it,
|
||||
/// Reject to drop it.
|
||||
fn revisions(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Ok(v) = app.session.execute("review.revisions", &json!({})) else {
|
||||
@@ -1210,9 +1211,14 @@ fn revisions(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ui.button(format!("Accept All ({})", changes.len())).clicked() {
|
||||
run(app, "review.accept", json!({"all": true}));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(format!("Accept All ({})", changes.len())).clicked() {
|
||||
run(app, "review.accept", json!({"all": true}));
|
||||
}
|
||||
if ui.button(format!("Reject All ({})", changes.len())).clicked() {
|
||||
run(app, "review.reject", json!({"all": true}));
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
for (i, c) in changes.iter().enumerate() {
|
||||
let slide = c["slide"].as_u64().unwrap_or(0);
|
||||
@@ -1231,6 +1237,9 @@ fn revisions(app: &mut SlideApp, ui: &mut Ui) {
|
||||
run(app, "slide.go", json!({"index": (slide as usize).min(max)}));
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.small_button("Reject").clicked() {
|
||||
run(app, "review.reject", json!({"index": i}));
|
||||
}
|
||||
if ui.small_button("Accept").clicked() {
|
||||
run(app, "review.accept", json!({"index": i}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! The Preferences (Options) dialog: controls apply as they change and Cancel, Esc and the
|
||||
//! window's close button put the settings back the way they were (#752, reported as #878).
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use egui_kittest::{Harness, Node};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::theme::Brightness;
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
/// The slider itself, not the label drawn beside it.
|
||||
fn interface_size<'a>(h: &'a Harness<'_, SlideApp>) -> Node<'a> {
|
||||
h.get_all_by_label("Interface size").find(|n| n.accesskit_node().role() == egui::accesskit::Role::Slider).expect("the slider is there")
|
||||
}
|
||||
|
||||
fn harness() -> Harness<'static, SlideApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
// The app keeps asking for frames, so step instead of run().
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn open_options(h: &mut Harness<'_, SlideApp>) {
|
||||
h.state_mut().run("app.preferences", json!({})).expect("open Options");
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn dark(app: &SlideApp) -> bool {
|
||||
app.ui.brightness == Brightness::Dark
|
||||
}
|
||||
|
||||
/// A checkbox applies as it is toggled and Cancel puts the old value back.
|
||||
#[test]
|
||||
fn options_cancel_undoes_the_changes() {
|
||||
let mut h = harness();
|
||||
let was = h.state().session.prefs.smart_guides;
|
||||
open_options(&mut h);
|
||||
|
||||
h.query_by_label("Show smart guides").expect("the checkbox is there").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.prefs.smart_guides, !was, "the change applies as it is made");
|
||||
|
||||
h.query_by_label("Cancel").expect("the Cancel button is there").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.prefs.smart_guides, was, "Cancel restores the setting");
|
||||
assert!(h.state().dialog.is_none(), "and the dialog closes");
|
||||
}
|
||||
|
||||
/// Every Editing setting and the name field go back on Cancel, not only the first checkbox: a
|
||||
/// reporter who changed a different control and saw Cancel do nothing has the same answer.
|
||||
#[test]
|
||||
fn options_cancel_undoes_every_editing_setting() {
|
||||
let mut h = harness();
|
||||
let before = h.state().session.prefs.clone();
|
||||
open_options(&mut h);
|
||||
|
||||
for label in ["Snap objects to grid", "Replace straight quotes with smart quotes", "AutoCorrect as you type"] {
|
||||
h.query_by_label(label).unwrap_or_else(|| panic!("the {label} checkbox is there")).click();
|
||||
h.run_steps(1);
|
||||
}
|
||||
// The name field: it writes into the settings as it is typed.
|
||||
// The name field is the last text input of the dialog (the label is a separate widget).
|
||||
let name = h.get_all_by_role(egui::accesskit::Role::TextInput).last().expect("the name field is there");
|
||||
name.click();
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::Text("Z".into()));
|
||||
h.run_steps(1);
|
||||
let changed = h.state().session.prefs.clone();
|
||||
assert_ne!(
|
||||
(changed.snap_to_grid, changed.smart_quotes, changed.autocorrect),
|
||||
(before.snap_to_grid, before.smart_quotes, before.autocorrect),
|
||||
"the settings apply as they are changed"
|
||||
);
|
||||
assert_ne!(changed.author, before.author, "the name applies as it is typed: {:?}", changed.author);
|
||||
|
||||
h.query_by_label("Cancel").expect("the Cancel button is there").click();
|
||||
h.run_steps(2);
|
||||
let after = h.state().session.prefs.clone();
|
||||
assert_eq!(after.snap_to_grid, before.snap_to_grid, "snap to grid is restored");
|
||||
assert_eq!(after.smart_quotes, before.smart_quotes, "smart quotes are restored");
|
||||
assert_eq!(after.autocorrect, before.autocorrect, "autocorrect is restored");
|
||||
assert_eq!(after.author, before.author, "the name is restored");
|
||||
}
|
||||
|
||||
/// The appearance toggle and the interface size go back on Cancel, too; Esc acts as Cancel.
|
||||
#[test]
|
||||
fn options_cancel_puts_the_appearance_and_scale_back() {
|
||||
let mut h = harness();
|
||||
let (dark_before, scale_before) = (dark(h.state()), h.state().ui.ui_scale);
|
||||
open_options(&mut h);
|
||||
|
||||
h.query_by_label("Dark appearance").expect("the checkbox is there").click();
|
||||
h.run_steps(2);
|
||||
assert!(dark(h.state()), "the appearance applies as it is toggled");
|
||||
|
||||
// Clicking the slider's middle sets it near the middle of its range, away from the default.
|
||||
interface_size(&h).click();
|
||||
h.run_steps(2);
|
||||
let scale_in_dialog = h.state().ui.ui_scale;
|
||||
assert!((scale_in_dialog - scale_before).abs() > 1e-3, "the slider applied a new size");
|
||||
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
assert_eq!(dark(h.state()), dark_before, "Esc restores the appearance");
|
||||
assert!((h.state().ui.ui_scale - scale_before).abs() < 1e-3, "Esc restores the size");
|
||||
assert!(h.state().dialog.is_none(), "and the dialog closes");
|
||||
}
|
||||
|
||||
/// The window's close button is the same as Cancel: the settings go back.
|
||||
#[test]
|
||||
fn options_close_button_restores_the_settings() {
|
||||
let mut h = harness();
|
||||
let was = h.state().session.prefs.smart_guides;
|
||||
open_options(&mut h);
|
||||
|
||||
h.query_by_label("Show smart guides").expect("the checkbox is there").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.prefs.smart_guides, !was, "the change applies as it is made");
|
||||
|
||||
h.get_all_by_label("Close window").next().expect("the window's close button is there").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.prefs.smart_guides, was, "the close button restores the setting");
|
||||
assert!(h.state().dialog.is_none(), "and the dialog closes");
|
||||
}
|
||||
@@ -15,7 +15,11 @@ fn harness() -> Harness<'static, SlideApp> {
|
||||
s.execute("format.font", &json!({"family": "Comic Neue"})).unwrap();
|
||||
s.execute("format.size", &json!({"size": 40})).unwrap();
|
||||
let app = SlideApp::new(s, Services::default());
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
// Home's ribbon is wider than it was when this test was written: at 1400 the Editing group
|
||||
// begins at x=1398, so the Replace Fonts button is two pixels wide on screen and a click at
|
||||
// its centre lands outside the window. The ribbon does not scroll sideways, so the window has
|
||||
// to be wide enough to hold it -- 1800 is what the other full-ribbon tests here use.
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1800.0, 1100.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
|
||||
+214
-9
@@ -316,6 +316,11 @@ impl Show {
|
||||
}
|
||||
}
|
||||
|
||||
/// True while the pointer moved in the last 2.5 s; the media controls and GIF button hide after that.
|
||||
fn pointer_awake(&self, now: f64) -> bool {
|
||||
now - self.last_move < 2.5
|
||||
}
|
||||
|
||||
/// Pen and laser are different pointers: turning one on puts the other away.
|
||||
fn set_pen(&mut self, on: bool) {
|
||||
self.pen = on;
|
||||
@@ -707,8 +712,13 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
&& show.zoom.is_none()
|
||||
&& !show.zoom_arm
|
||||
&& !show.reading_bar_hit(&ctx)
|
||||
&& !pointer_on_media_bar(&ctx, ui, &doc, &show);
|
||||
let gif_click = if clicked && on_slide && doc.show.show_media_controls { gif_hit(&ctx, ui, &show, doc_uid, &doc, app) } else { None };
|
||||
&& !pointer_on_media_bar(&ctx, ui, &doc, &show, now);
|
||||
// The button is drawn only while the pointer is awake; a hidden button takes no click.
|
||||
let gif_click = if clicked && on_slide && doc.show.show_media_controls && show.pointer_awake(now) {
|
||||
gif_hit(&ctx, ui, &show, doc_uid, &doc, app)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let media_click = if clicked && on_slide && gif_click.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None };
|
||||
let under = if on_slide && gif_click.is_none() && media_click.is_none() { shapes_under_pointer(&ctx, ui, &mut show, &doc, now) } else { None };
|
||||
let pointing = under.as_ref().is_some_and(|u| u.pick.is_some());
|
||||
@@ -745,7 +755,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
} else if clicked && show.zoom.is_some() && !show.pen && !show.reading_bar_hit(&ctx) {
|
||||
show.dismiss_zoom();
|
||||
} else if clicked && !show.pen && !show.reading_bar_hit(&ctx) && !pointer_on_media_bar(&ctx, ui, &doc, &show) {
|
||||
} else if clicked && !show.pen && !show.reading_bar_hit(&ctx) && !pointer_on_media_bar(&ctx, ui, &doc, &show, now) {
|
||||
if show.ended {
|
||||
keep = false;
|
||||
} else if show.blank.is_some() {
|
||||
@@ -1041,7 +1051,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
// Not while zoomed: the controls sit where the whole slide would be.
|
||||
if doc.show.show_media_controls && !show.zoom_arm && show.zoom.is_none() {
|
||||
let k = srect.width() / sw.max(1.0);
|
||||
if let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| !show.pen && !show.laser && now - show.last_move < 2.5) {
|
||||
if let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| !show.pen && !show.laser && show.pointer_awake(now)) {
|
||||
for g in &gifs {
|
||||
let r = on_screen(srect, k, &g.rect);
|
||||
if r.contains(p) {
|
||||
@@ -1050,7 +1060,8 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((id, clip, bar)) = media_bar_target(&ctx, &doc, &show, srect) {
|
||||
if let Some((id, clip, bar)) = media_bar_target(&ctx, &doc, &show, srect).filter(|_| show.pointer_awake(now)) {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(500));
|
||||
let action = {
|
||||
let status = app.media.status(id);
|
||||
crate::media::control_bar(ui, bar, status.as_ref(), &clip)
|
||||
@@ -1101,7 +1112,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::None);
|
||||
} else if show.zoom_arm || show.grid {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
} else if now - show.last_move > 2.5 && !show.reading {
|
||||
} else if !show.pointer_awake(now) && !show.reading {
|
||||
// Idle first: a still pointer hides also over a clickable shape, such as a playing clip.
|
||||
ctx.set_cursor_icon(egui::CursorIcon::None);
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(500));
|
||||
@@ -1161,7 +1172,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let a = show.state.prev(&doc);
|
||||
show.apply(a, &doc, now);
|
||||
}
|
||||
} else if !show.grid && (now - show.last_move < 2.5 || show.zoom_arm) {
|
||||
} else if !show.grid && (show.pointer_awake(now) || show.zoom_arm) {
|
||||
let bar = tool_bar_rect(full);
|
||||
painter.rect_filled(bar, CornerRadius::same(17), Color32::from_black_alpha(140));
|
||||
let mut child =
|
||||
@@ -1349,8 +1360,8 @@ fn media_bar_target(ctx: &egui::Context, doc: &Presentation, show: &Show, srect:
|
||||
}
|
||||
|
||||
/// True when the pointer is on a visible playback bar, so that click must not advance the show.
|
||||
fn pointer_on_media_bar(ctx: &egui::Context, ui: &Ui, doc: &Presentation, show: &Show) -> bool {
|
||||
if !doc.show.show_media_controls || show.pen || show.ended || show.blank.is_some() {
|
||||
fn pointer_on_media_bar(ctx: &egui::Context, ui: &Ui, doc: &Presentation, show: &Show, now: f64) -> bool {
|
||||
if !doc.show.show_media_controls || !show.pointer_awake(now) || show.pen || show.ended || show.blank.is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(p) = ctx.input(|i| i.pointer.interact_pos().or(i.pointer.hover_pos())) else { return false };
|
||||
@@ -2190,6 +2201,200 @@ mod tests {
|
||||
assert!(!harness.state().1.presenter_grid, "G again closes it");
|
||||
}
|
||||
|
||||
/// A silent mono 8 kHz WAV, so the clip has a real duration to seek in.
|
||||
fn silent_wav(secs: u32) -> Vec<u8> {
|
||||
let rate = 8_000u32;
|
||||
let data = vec![0u8; (rate * secs * 2) as usize];
|
||||
let mut b = Vec::new();
|
||||
b.extend_from_slice(b"RIFF");
|
||||
b.extend_from_slice(&(36 + data.len() as u32).to_le_bytes());
|
||||
b.extend_from_slice(b"WAVEfmt ");
|
||||
b.extend_from_slice(&16u32.to_le_bytes());
|
||||
b.extend_from_slice(&1u16.to_le_bytes());
|
||||
b.extend_from_slice(&1u16.to_le_bytes());
|
||||
b.extend_from_slice(&rate.to_le_bytes());
|
||||
b.extend_from_slice(&(rate * 2).to_le_bytes());
|
||||
b.extend_from_slice(&2u16.to_le_bytes());
|
||||
b.extend_from_slice(&16u16.to_le_bytes());
|
||||
b.extend_from_slice(b"data");
|
||||
b.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
b.extend_from_slice(&data);
|
||||
b
|
||||
}
|
||||
|
||||
/// A running show on a slide with one 10 s clip. Returns the harness, the clip, a point on the
|
||||
/// bar's seek track, and what `pointer_on_media_bar` said on the last frame.
|
||||
fn show_with_clip() -> (egui_kittest::Harness<'static, SlideApp>, ShapeId, Pos2, std::rc::Rc<std::cell::Cell<bool>>) {
|
||||
let size = vec2(1000.0, 700.0);
|
||||
let mut session = deckcraft_engine::Session::with_new();
|
||||
let id = ShapeId(4000);
|
||||
session
|
||||
.edit(|doc, _| {
|
||||
let media = doc.add_media("clip.wav", "audio/wav", silent_wav(10)).unwrap();
|
||||
let (w, h) = (doc.slide_size.width, doc.slide_size.height);
|
||||
let clip = deckcraft_model::MediaClip { media, video: true, volume: 1.0, duration_ms: 10_000, ..Default::default() };
|
||||
let xfrm = deckcraft_geom::Xfrm::new(w * 0.2, h * 0.2, w * 0.6, h * 0.6);
|
||||
let slide = std::sync::Arc::make_mut(&mut doc.slides[0]);
|
||||
slide.shapes.push(deckcraft_model::Shape {
|
||||
id,
|
||||
xfrm: Some(xfrm),
|
||||
kind: deckcraft_model::ShapeKind::Media(clip),
|
||||
..Default::default()
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let on_bar = std::rc::Rc::new(std::cell::Cell::new(false));
|
||||
let seen = on_bar.clone();
|
||||
let slide = std::rc::Rc::new(std::cell::Cell::new(Rect::NOTHING));
|
||||
let slide_seen = slide.clone();
|
||||
// Presenter view opens an embedded window over the whole screen here, which takes the clicks.
|
||||
let mut app = SlideApp::new(session, crate::Services::default());
|
||||
app.ui.presenter_view = false;
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(size).with_step_dt(0.25).build_ui_state(
|
||||
move |ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
if let (Some(show), Some(d)) = (app.show.as_ref(), app.session.active()) {
|
||||
let now = ui.input(|i| i.time);
|
||||
seen.set(pointer_on_media_bar(ui.ctx(), ui, &d.doc, show, now));
|
||||
slide_seen.set(shown_slide_rect(ui, show, &d.doc));
|
||||
}
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.run_steps(3);
|
||||
harness.state_mut().start_show(0, false);
|
||||
harness.run_steps(2);
|
||||
let doc = harness.state().session.active().unwrap().doc.clone();
|
||||
let bar = slide_media_bars(slide.get(), &doc, 0)[0].3;
|
||||
let track = pos2(bar.min.x + bar.width() * 0.8, bar.center().y);
|
||||
(harness, id, track, on_bar)
|
||||
}
|
||||
|
||||
/// Press and release the primary button at `p`.
|
||||
fn click_at(harness: &mut egui_kittest::Harness<'static, SlideApp>, p: Pos2) {
|
||||
for pressed in [true, false] {
|
||||
harness.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
|
||||
harness.step();
|
||||
}
|
||||
}
|
||||
|
||||
/// #967: hovering a clip shows its bar and a click on the bar seeks. After 3 s without moving
|
||||
/// the bar is gone, and a click where it was acts on the clip itself (play), not on the bar.
|
||||
#[test]
|
||||
fn media_controls_hide_when_pointer_is_idle() {
|
||||
// Awake: the bar is there and a click on its timeline seeks, leaving the clip paused.
|
||||
let (mut harness, id, track, on_bar) = show_with_clip();
|
||||
harness.hover_at(track);
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().show.as_ref().unwrap().pointer_awake(harness.ctx.input(|i| i.time)));
|
||||
assert!(on_bar.get(), "the bar is under a pointer that just moved");
|
||||
click_at(&mut harness, track);
|
||||
harness.run_steps(2);
|
||||
let status = harness.state().media.status(id).expect("the click on the bar cued the clip");
|
||||
assert!(!harness.state().media.is_playing(id), "a bar click seeks, it does not play");
|
||||
assert!(status.position > 1.0, "seeked along the timeline, at {}", status.position);
|
||||
|
||||
// Idle: same spot, no movement for 3 s.
|
||||
let (mut harness, id, track, on_bar) = show_with_clip();
|
||||
harness.hover_at(track);
|
||||
harness.run_steps(2);
|
||||
assert!(on_bar.get());
|
||||
harness.run_steps(12);
|
||||
assert!(!harness.state().show.as_ref().unwrap().pointer_awake(harness.ctx.input(|i| i.time)));
|
||||
assert!(!on_bar.get(), "the bar is hidden once the pointer is idle");
|
||||
click_at(&mut harness, track);
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().media.is_playing(id), "the click reached the clip");
|
||||
let at = harness.state().media.status(id).map_or(0.0, |s| s.position);
|
||||
assert!(at < 5.0, "the click did not seek along the timeline, position {at}");
|
||||
}
|
||||
|
||||
/// Two 1×1 frames (black, then white), 100 ms each.
|
||||
fn two_frame_gif() -> Vec<u8> {
|
||||
let mut b = b"GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff".to_vec();
|
||||
for pixel in [b"\x44\x01", b"\x4c\x01"] {
|
||||
b.extend_from_slice(b"\x21\xf9\x04\x00\x0a\x00\x00\x00");
|
||||
b.extend_from_slice(b"\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02");
|
||||
b.extend_from_slice(pixel);
|
||||
b.push(0);
|
||||
}
|
||||
b.push(0x3b);
|
||||
b
|
||||
}
|
||||
|
||||
/// A running show on the first of two slides; slide 1 holds a large animated GIF. Returns the
|
||||
/// harness, the GIF, and its play/pause button's centre.
|
||||
fn show_with_gif() -> (egui_kittest::Harness<'static, SlideApp>, ShapeId, Pos2) {
|
||||
let mut session = deckcraft_engine::Session::with_new();
|
||||
// The GIF is on slide 1, the slide the editor has selected, with a slide after it.
|
||||
session.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
session.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
session.execute("slide.go", &json!({"index": 1})).unwrap();
|
||||
let id = ShapeId(4100);
|
||||
session
|
||||
.edit(|doc, _| {
|
||||
let media = doc.add_media("a.gif", "image/gif", two_frame_gif()).unwrap();
|
||||
let (w, h) = (doc.slide_size.width, doc.slide_size.height);
|
||||
let slide = std::sync::Arc::make_mut(&mut doc.slides[1]);
|
||||
slide.shapes.push(deckcraft_model::Shape {
|
||||
id,
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.2, h * 0.2, w * 0.6, h * 0.6)),
|
||||
kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } },
|
||||
..Default::default()
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let slide = std::rc::Rc::new(std::cell::Cell::new(Rect::NOTHING));
|
||||
let slide_seen = slide.clone();
|
||||
let mut app = SlideApp::new(session, crate::Services::default());
|
||||
app.ui.presenter_view = false;
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(0.25).build_ui_state(
|
||||
move |ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
if let (Some(show), Some(d)) = (app.show.as_ref(), app.session.active()) {
|
||||
slide_seen.set(shown_slide_rect(ui, show, &d.doc));
|
||||
}
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.run_steps(3);
|
||||
harness.state_mut().start_show(1, false);
|
||||
harness.run_steps(2);
|
||||
let doc = harness.state().session.active().unwrap().doc.clone();
|
||||
let srect = slide.get();
|
||||
let k = srect.width() / doc.slide_size.width.max(1.0) as f32;
|
||||
let rect = doc.slides[1].shapes.last().unwrap().xfrm.unwrap();
|
||||
let button = crate::media::gif_button_rect(on_screen(srect, k, &rect)).center();
|
||||
(harness, id, button)
|
||||
}
|
||||
|
||||
/// The GIF's play/pause button is drawn only while the pointer is awake, so a click on its
|
||||
/// spot after 2.5 s without movement advances the show instead of pausing the GIF.
|
||||
#[test]
|
||||
fn gif_button_click_needs_an_awake_pointer() {
|
||||
let (mut harness, id, button) = show_with_gif();
|
||||
harness.hover_at(button);
|
||||
harness.run_steps(2);
|
||||
click_at(&mut harness, button);
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().session.gif_paused.contains(&id), "an awake pointer's click pauses the GIF");
|
||||
assert_eq!(harness.state().show.as_ref().unwrap().state.slide, 1);
|
||||
|
||||
let (mut harness, id, button) = show_with_gif();
|
||||
harness.hover_at(button);
|
||||
harness.run_steps(2);
|
||||
harness.run_steps(12);
|
||||
assert!(!harness.state().show.as_ref().unwrap().pointer_awake(harness.ctx.input(|i| i.time)));
|
||||
click_at(&mut harness, button);
|
||||
harness.run_steps(2);
|
||||
assert!(!harness.state().session.gif_paused.contains(&id), "the hidden button is not clickable");
|
||||
assert_eq!(harness.state().show.as_ref().unwrap().state.slide, 2, "the click advanced the show");
|
||||
}
|
||||
|
||||
/// Send real egui key events to the presenter UI, without the audience window's handler.
|
||||
#[test]
|
||||
fn presenter_navigation_uses_its_own_keyboard_events() {
|
||||
|
||||
@@ -169,3 +169,13 @@ fn dragging_a_section_moves_it_and_keeps_it_collapsed() {
|
||||
let (beta, alpha) = (h.get_by_label("Beta").rect(), h.get_by_label("Alpha").rect());
|
||||
assert!(beta.top() < alpha.top());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_a_selects_every_slide() {
|
||||
let mut h = harness(false);
|
||||
h.key_combination_modifiers(egui::Modifiers::COMMAND, &[egui::Key::A]);
|
||||
h.run_steps(2);
|
||||
let st = h.state().session.active().unwrap();
|
||||
let all = st.doc.slides.len();
|
||||
assert_eq!(st.selection.slides.len(), all, "Ctrl+A in the sorter selects every slide");
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ use crate::icons::{self, Icon};
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{SlideApp, ViewMode};
|
||||
|
||||
/// The status bar: slide counter, proofing language and status message on the left; notes,
|
||||
/// comments, views and zoom on the right.
|
||||
pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Panel::bottom("status").exact_size(28.0).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
|
||||
@@ -19,22 +21,31 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
|
||||
_ => "Slide Master".to_string(),
|
||||
};
|
||||
let p = ui.painter();
|
||||
p.text(pos2(rect.min.x + 14.0, rect.center().y), Align2::LEFT_CENTER, &left, theme::font(12.0), t.text_dim);
|
||||
// Left to right: slide counter, language, status message. Each starts at its usual column, or
|
||||
// after the one before it when that runs long ("Slide 100 of 120", "… (not checked)").
|
||||
let sr = p.text(pos2(rect.min.x + 14.0, rect.center().y), Align2::LEFT_CENTER, &left, theme::font(12.0), t.text_dim);
|
||||
// Where the controls on the right begin; nothing on the left paints or clicks past it.
|
||||
let right_x = rect.max.x - 560.0;
|
||||
// The selection's proofing language; a click opens Review › Language.
|
||||
let (lang, no_proof) = crate::dialogs::selected_language(app);
|
||||
let name = crate::squiggles::lang_name(&lang) + if no_proof { " (not checked)" } else { "" };
|
||||
let lr = p.text(pos2(rect.min.x + 120.0, rect.center().y), Align2::LEFT_CENTER, &name, theme::font(12.0), t.text_dim);
|
||||
if ui.interact(lr.expand(3.0), ui.id().with("status_lang"), Sense::click()).on_hover_text("Language").clicked() {
|
||||
let lx = (rect.min.x + 120.0).max(sr.max.x + 16.0);
|
||||
let lclip = Rect::from_min_max(pos2(lx - 3.0, rect.min.y), pos2(right_x.max(lx - 3.0), rect.max.y));
|
||||
let lr = p.with_clip_rect(lclip).text(pos2(lx, rect.center().y), Align2::LEFT_CENTER, &name, theme::font(12.0), t.text_dim);
|
||||
let hit = lr.expand(3.0).intersect(lclip);
|
||||
if hit.is_positive() && ui.interact(hit, ui.id().with("status_lang"), Sense::click()).on_hover_text("Language").clicked() {
|
||||
app.dialog = Some(crate::dialogs::Dialog::new("language"));
|
||||
}
|
||||
// Transient status message.
|
||||
// Transient status message, clipped before the controls on the right.
|
||||
if let Some(msg) = app.fresh_status() {
|
||||
p.text(pos2(rect.min.x + 290.0, rect.center().y), Align2::LEFT_CENTER, &msg, theme::font(12.0), t.accent);
|
||||
let mx = (rect.min.x + 290.0).max(lr.max.x + 16.0);
|
||||
let clip = Rect::from_min_max(pos2(mx, rect.min.y), pos2(right_x.max(mx), rect.max.y));
|
||||
p.with_clip_rect(clip).text(pos2(mx, rect.center().y), Align2::LEFT_CENTER, &msg, theme::font(12.0), t.accent);
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
}
|
||||
let mut right = ui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
.max_rect(Rect::from_min_max(pos2(rect.max.x - 560.0, rect.min.y), pos2(rect.max.x - 8.0, rect.max.y)))
|
||||
.max_rect(Rect::from_min_max(pos2(right_x, rect.min.y), pos2(rect.max.x - 8.0, rect.max.y)))
|
||||
.layout(egui::Layout::right_to_left(egui::Align::Center)),
|
||||
);
|
||||
right.spacing_mut().item_spacing.x = 4.0;
|
||||
@@ -248,3 +259,54 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
|
||||
app.notes_buf.2 = draft;
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
/// A long proofing language in a narrow window must not take clicks meant for the controls on
|
||||
/// the right: Notes toggles the notes pane and the Language dialog stays closed.
|
||||
#[test]
|
||||
fn a_long_language_name_leaves_the_notes_button_clickable() {
|
||||
let mut app = SlideApp::new(Session::with_new(), Services::default());
|
||||
app.session.execute("shape.insert", &json!({"preset": "rect", "rect": [80, 120, 400, 100], "text": "hello"})).unwrap();
|
||||
app.session.execute("format.language", &json!({"lang": "en-US-x-a-very-long-private-use-tag"})).unwrap();
|
||||
app.ui.notes = false;
|
||||
let size = egui::vec2(700.0, 800.0);
|
||||
let mut h = Harness::builder().with_size(size).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().ui.notes);
|
||||
// The controls on the right start 560 points from the bar's right edge (x 132); the Notes
|
||||
// button is the leftmost of them (x 167..231 at this width). The bar ends 8 points above
|
||||
// the bottom edge and is 28 points high.
|
||||
let y = size.y - 8.0 - 14.0;
|
||||
let click = |h: &mut Harness<'_, SlideApp>, x: f32| {
|
||||
let at = egui::pos2(x, y);
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
}
|
||||
h.run_steps(3);
|
||||
};
|
||||
// The gap before the controls is outside the language name's clickable area.
|
||||
click(&mut h, 150.0);
|
||||
assert!(h.state().dialog.is_none(), "the Language dialog stayed closed");
|
||||
click(&mut h, 200.0);
|
||||
assert!(h.state().ui.notes, "the click reached the Notes button");
|
||||
assert!(h.state().dialog.is_none(), "the Language dialog stayed closed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ pub struct Tokens {
|
||||
/// The ribbon card and popovers.
|
||||
pub card: Color32,
|
||||
pub card_border: Color32,
|
||||
/// Text fields. They have no outline, so the fill alone must set them apart from the card.
|
||||
pub text_field: Color32,
|
||||
/// Canvas around the slide.
|
||||
pub canvas: Color32,
|
||||
pub text: Color32,
|
||||
@@ -41,6 +43,12 @@ pub struct Tokens {
|
||||
pub caret: Color32,
|
||||
pub text_selection: Color32,
|
||||
pub field_bg: Color32,
|
||||
/// Unchecked checkbox and radio boxes and slider rails, drawn without an outline, so they
|
||||
/// must stand apart from the card and the chrome.
|
||||
pub control: Color32,
|
||||
/// [`Tokens::control`] under the pointer and pressed: a step darker each in light mode.
|
||||
pub control_hover: Color32,
|
||||
pub control_pressed: Color32,
|
||||
pub shadow: Color32,
|
||||
}
|
||||
|
||||
@@ -51,6 +59,7 @@ impl Tokens {
|
||||
chrome: Color32::from_rgb(0xF5, 0xF5, 0xF5),
|
||||
card: Color32::WHITE,
|
||||
card_border: Color32::from_rgb(0xE3, 0xE3, 0xE3),
|
||||
text_field: Color32::from_rgb(0xEB, 0xEB, 0xEB),
|
||||
canvas: Color32::from_rgb(0xF0, 0xF0, 0xF0),
|
||||
text: Color32::from_rgb(0x24, 0x24, 0x24),
|
||||
text_dim: Color32::from_rgb(0x5C, 0x5C, 0x5C),
|
||||
@@ -70,6 +79,9 @@ impl Tokens {
|
||||
caret: Color32::BLACK,
|
||||
text_selection: Color32::from_rgba_unmultiplied(0x5A, 0x8F, 0xE0, 90),
|
||||
field_bg: Color32::WHITE,
|
||||
control: Color32::from_rgb(0xD6, 0xD6, 0xD6),
|
||||
control_hover: Color32::from_rgb(0xC8, 0xC8, 0xC8),
|
||||
control_pressed: Color32::from_rgb(0xBC, 0xBC, 0xBC),
|
||||
shadow: Color32::from_black_alpha(28),
|
||||
}
|
||||
}
|
||||
@@ -79,6 +91,7 @@ impl Tokens {
|
||||
chrome: Color32::from_rgb(0x26, 0x26, 0x26),
|
||||
card: Color32::from_rgb(0x33, 0x33, 0x33),
|
||||
card_border: Color32::from_rgb(0x44, 0x44, 0x44),
|
||||
text_field: Color32::from_rgb(0x2A, 0x2A, 0x2A),
|
||||
canvas: Color32::from_rgb(0x1E, 0x1E, 0x1E),
|
||||
text: Color32::from_rgb(0xEE, 0xEE, 0xEE),
|
||||
text_dim: Color32::from_rgb(0xBB, 0xBB, 0xBB),
|
||||
@@ -98,6 +111,9 @@ impl Tokens {
|
||||
caret: Color32::BLACK,
|
||||
text_selection: Color32::from_rgba_unmultiplied(0x5A, 0x8F, 0xE0, 90),
|
||||
field_bg: Color32::from_rgb(0x2A, 0x2A, 0x2A),
|
||||
control: Color32::from_rgb(0x2A, 0x2A, 0x2A),
|
||||
control_hover: Color32::from_rgb(0x40, 0x40, 0x40),
|
||||
control_pressed: Color32::from_rgb(0x4C, 0x4C, 0x4C),
|
||||
shadow: Color32::from_black_alpha(90),
|
||||
}
|
||||
}
|
||||
@@ -118,6 +134,7 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) {
|
||||
v.panel_fill = t.chrome;
|
||||
v.window_fill = t.card;
|
||||
v.extreme_bg_color = t.field_bg;
|
||||
v.text_edit_bg_color = Some(t.text_field);
|
||||
v.faint_bg_color = t.hover;
|
||||
v.window_stroke = Stroke::new(1.0, t.card_border);
|
||||
v.window_corner_radius = CornerRadius::same(10);
|
||||
@@ -131,13 +148,13 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) {
|
||||
}
|
||||
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.separator);
|
||||
v.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
|
||||
v.widgets.inactive.bg_fill = t.field_bg;
|
||||
v.widgets.inactive.bg_fill = t.control;
|
||||
v.widgets.inactive.bg_stroke = Stroke::NONE;
|
||||
v.widgets.hovered.weak_bg_fill = t.hover;
|
||||
v.widgets.hovered.bg_fill = t.hover;
|
||||
v.widgets.hovered.bg_fill = t.control_hover;
|
||||
v.widgets.hovered.bg_stroke = Stroke::NONE;
|
||||
v.widgets.active.weak_bg_fill = t.pressed;
|
||||
v.widgets.active.bg_fill = t.pressed;
|
||||
v.widgets.active.bg_fill = t.control_pressed;
|
||||
v.widgets.open.weak_bg_fill = t.pressed;
|
||||
v.window_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 18, spread: 0, color: t.shadow };
|
||||
v.popup_shadow = egui::epaint::Shadow { offset: [0, 3], blur: 12, spread: 0, color: t.shadow };
|
||||
@@ -257,6 +274,24 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_checkboxes_and_slider_rails_stand_out_from_their_background() {
|
||||
// An unchecked checkbox and a slider rail are filled with the inactive widget fill and
|
||||
// have no outline, so in light mode they vanished into the white dialog (#766).
|
||||
let ctx = egui::Context::default();
|
||||
super::apply(&ctx, &super::Tokens::light());
|
||||
let v = ctx.global_style().visuals.clone();
|
||||
assert_eq!(v.widgets.inactive.bg_stroke, egui::Stroke::NONE, "the test assumes no outline");
|
||||
let fill = v.widgets.inactive.bg_fill;
|
||||
for (name, bg) in [("dialog", v.window_fill), ("panel", v.panel_fill)] {
|
||||
let diff = [fill.r().abs_diff(bg.r()), fill.g().abs_diff(bg.g()), fill.b().abs_diff(bg.b())];
|
||||
assert!(diff.iter().all(|d| *d >= 24), "control fill {fill:?} is too close to the {name} background {bg:?}");
|
||||
}
|
||||
// Hovering and pressing darken the control a step each, as they do buttons.
|
||||
let (hovered, active) = (v.widgets.hovered.bg_fill, v.widgets.active.bg_fill);
|
||||
assert!(fill.r() > hovered.r() && hovered.r() > active.r(), "idle {fill:?}, hovered {hovered:?}, active {active:?} don't darken in turn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thin_punctuation_is_drawn_in_interface_text() {
|
||||
// En and em dashes (thin bars) were hinted down to nothing in the Japanese interface face,
|
||||
|
||||
@@ -26,11 +26,13 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
master_list(app, ui);
|
||||
return;
|
||||
}
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
let out = egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.add_space(8.0);
|
||||
list(app, ui);
|
||||
ui.add_space(20.0);
|
||||
});
|
||||
// Where the pane is scrolled to, for the control channel and tests (#917).
|
||||
app.ui.thumbs_scroll = out.state.offset.y;
|
||||
});
|
||||
// Focus follows the last press: inside the pane keeps it, anywhere else (canvas, ribbon) drops it.
|
||||
if let Some(pos) = ui.input(|i| if i.pointer.primary_pressed() { i.pointer.interact_pos() } else { None }) {
|
||||
@@ -47,6 +49,16 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let width = ui.available_width();
|
||||
let tw = (width - 40.0).max(60.0);
|
||||
let th = tw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32;
|
||||
// Scroll the current slide into view when it changes, so arrow-key navigation follows
|
||||
// along in the pane (#917). The scroll itself is minimal, only when the thumbnail leaves
|
||||
// the view, as in PowerPoint, so the user's own scrolling stands; the first frame only
|
||||
// records the slide.
|
||||
let scroll_key = ui.id().with("thumb_scrolled_to");
|
||||
let slide_changed = ui.data_mut(|d| {
|
||||
let last: Option<usize> = d.get_temp(scroll_key);
|
||||
d.insert_temp(scroll_key, cur);
|
||||
last.is_some_and(|last| last != cur)
|
||||
});
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let mut budget = 3u32;
|
||||
let mut rects = vec![];
|
||||
@@ -170,6 +182,10 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
if need_more {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
// Scroll the current slide's thumbnail into view when the slide changes (#917).
|
||||
if slide_changed && let Some((_, row_rect)) = rects.iter().find(|(i, _)| *i == cur) {
|
||||
ui.scroll_to_rect(*row_rect, None);
|
||||
}
|
||||
// Drag reorder: insertion line.
|
||||
if let Some((from, _)) = app.thumb_drag {
|
||||
if let Some(pp) = ui.input(|i| i.pointer.interact_pos()) {
|
||||
@@ -327,3 +343,58 @@ fn master_list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use egui_kittest::Harness;
|
||||
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
use deckcraft_engine::Session;
|
||||
|
||||
fn harness() -> Harness<'static, SlideApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
/// Vertical scroll offset of the thumbnail pane, as recorded each frame by `show`.
|
||||
fn thumbs_scroll(h: &Harness<'_, SlideApp>) -> f32 {
|
||||
h.state().ui.thumbs_scroll
|
||||
}
|
||||
|
||||
/// #917: navigating to a slide below the fold scrolls the pane to reveal it; a neighbour
|
||||
/// that is already on screen does not move the pane.
|
||||
#[test]
|
||||
fn navigating_slides_keeps_the_current_thumbnail_visible() {
|
||||
let mut h = harness();
|
||||
for _ in 0..30 {
|
||||
h.state_mut().session.execute("slide.new", &json!({})).unwrap();
|
||||
}
|
||||
// slide.new selects each new slide; back to the first so the pane starts at the top.
|
||||
h.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
h.run_steps(3);
|
||||
let last = h.state().session.active().unwrap().doc.slides.len() - 1;
|
||||
assert_eq!(thumbs_scroll(&h), 0.0, "the pane starts at the top");
|
||||
|
||||
// Jump to the last slide: the pane follows and reveals it.
|
||||
h.state_mut().session.execute("slide.go", &json!({"index": last})).unwrap();
|
||||
h.run_steps(120); // the scroll eases in over several frames
|
||||
let jumped = thumbs_scroll(&h);
|
||||
assert!(jumped > 100.0, "the pane scrolled to the last slide, offset {jumped}");
|
||||
|
||||
// One slide up was already on screen after that scroll: the pane holds still.
|
||||
h.state_mut().session.execute("slide.go", &json!({"index": last - 1})).unwrap();
|
||||
h.run_steps(120);
|
||||
let held = thumbs_scroll(&h);
|
||||
assert!((held - jumped).abs() < 1.0, "no re-scroll for a visible slide: {jumped} -> {held}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
|
||||
| Start On Click / With Previous / After Previous, duration, delay | yes | yes |
|
||||
| Effect Options (direction, sequence by paragraph) | yes | yes |
|
||||
| Effect dialog: sound, after-animation dim/hide, animate text by word/letter, smooth start/end, bounce end, repeat, rewind | yes | partly; repeat and bounce end round-trip; rewind lost on PPTX save (#77) |
|
||||
| By-paragraph text builds, sub-bullets follow parents | yes | yes |
|
||||
| By-paragraph text builds, sub-bullets follow parents | yes | yes; builds edited in PowerPoint (own start per paragraph, other effects in between, by 2nd-level paragraphs) import as one effect per paragraph and keep their clicks (#844) |
|
||||
| Triggers (on click of a shape) | yes | yes; played in the show: a step per click, the sequence starts over after its last step, a click completes a playing step. Separate click steps, mixed starts and paragraph builds survive PPTX save (`trigger_steps.rs`) |
|
||||
| Triggers on a media bookmark | yes | no |
|
||||
| Animation Painter | yes | yes (#54) |
|
||||
@@ -96,6 +96,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
|
||||
|---|---|---|
|
||||
| 2026-10-22 | minor | Animation Pane ▸ Play From: with an effect selected, the preview starts at that effect on the play-all clock; the row says yes and the estimate drops to ~14–26 h |
|
||||
| 2026-10-15 | trivial | PPTX interactive sequences preserve later On Click effects and one click per paragraph; no change to playback or feature scores |
|
||||
| 2026-10-16 | trivial | PPTX paragraph builds edited in PowerPoint keep their clicks; sub-points save With Previous of their parent (#844) |
|
||||
| 2026-10-14 | trivial | Community PR wave folded: the Animation Pane timeline row says yes (it landed), preview split into canvas (yes) and from the selected effect (no); estimates drop the timeline and transition sounds (~15–27 h) |
|
||||
| 2026-10-14 | trivial | Repeat and bounce end round-trip through PPTX; rewind is still lost (#77) |
|
||||
| 2026-10-13 | minor | Animations ▸ Preview and Animation Pane ▸ Play All play the current slide's animations on the editor canvas; preview from the selected effect is still open |
|
||||
|
||||
@@ -22,10 +22,10 @@ PowerPoint opens and saves; Viewer = opens only).
|
||||
| `.pptm` / `.potm` / `.ppsm` macro-enabled | Editor | no | no | Should open and keep `vbaProject.bin` without running it | none |
|
||||
| `.ppt` / `.pot` / `.pps` 97–2003 binary | Editor | no | no | Owner decision; import only would be enough | none |
|
||||
| `.odp` OpenDocument | Editor | no | no | | none |
|
||||
| `.thmx` Office theme | Viewer | no | no | Browse for themes is a P2 row | none |
|
||||
| `.thmx` Office theme | Viewer | theme only (Design › Browse for Themes…) | no | Browse for Themes takes a `.thmx`'s colours, fonts and effects; a `.pptx` or `.potx` brings its slide master and layouts as well, and slides move to the matching layouts (#807). A `.thmx`'s own master and layouts aren't read yet | `crates/pptx/src/lib.rs` (`import_theme_tests`), `crates/engine/src/cmd/design.rs` (`browse_theme_tests`) |
|
||||
| `.deckcraft` native (zip + JSON) | n/a | yes (also `.slidecraft`) | yes | Lossless for the DeckCraft model, forward compatible | `crates/format` tests |
|
||||
| PDF | export | no | yes | Slides, notes pages, handouts (1–9); real-text layer with embedded subsets, links, bookmarks. Slide artwork is a raster image at print resolution, not vector | `crates/pdf` tests |
|
||||
| Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt` outline; `.md` as Markdown (headings are slides, list items bullets) (`slide.fromOutline`) | `.txt` outline | No RTF | engine tests |
|
||||
| Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt` and `.rtf` outline; `.md` as Markdown (headings are slides, list items bullets) (open, drop, `slide.fromOutline`) | `.txt` and `.rtf` outline | — | format + engine tests |
|
||||
| PNG / JPEG slide images | export | n/a | yes (`file.export`, `deckcraft-cli render`) | Matches the editor's renderer. PNG `pHYs` and JPEG JFIF density are `scale` × 72 dpi (default scale 2 is 144 dpi), so Preview shows the slide's real size. PNG can leave the slide background out (`transparentBackground`, Export… “Transparent background”, `deckcraft-cli render --transparent`), so only the slide's content is opaque (#784) | render, engine, UI and CLI tests |
|
||||
| TIFF / BMP / GIF slide images | export | n/a | no | Easy (the `image` crate writes them) | none |
|
||||
| Animated GIF of the deck | export | n/a | yes (`file.export` format `gif`, Export…, `deckcraft-cli`, MCP `export`) | One frame per slide from the slide renderer; size small/medium/large/extra large or 16–3840 px wide, 0.1–600 s per slide, loop forever or play once, slide range, hidden slides skipped; a 256-colour palette per frame (exact, or NeuQuant above 256 colours). Transitions and build animations are not animated | `crates/engine/src/gif_export.rs` tests (decoded frames, size, delay, loop, hidden slides, hostile params), MCP, CLI and export dialog tests |
|
||||
@@ -124,7 +124,9 @@ the round trip even though DeckCraft doesn't model it.
|
||||
| 2026-10-15 | trivial | PPTX interactive timing sequences keep later On Click steps and paragraph click builds (`trigger_steps.rs`); mixed starts and independent buttons survive import/edit/save/reopen |
|
||||
| 2026-10-15 | minor | A `.pptx` slide that can't be read is reported instead of dropped in silence: `file.open` and `file.openBytes` return `lostSlides`, the app shows a message and opens a repaired copy (Save asks for a new name), and `deckcraft-cli` warns on stderr (#203) |
|
||||
| 2026-10-16 | minor | PNG slide export with a transparent background (#784) |
|
||||
| 2026-10-23 | minor | Chart text colour (`c:txPr` on the axes, legend, data labels and chart; the title's run defaults) is written as well as read, so an imported chart's colours survive an edit and a save; `chart.options {textColor}`, Font Color on a chart (#1015) |
|
||||
| 2026-10-16 | trivial | A `.ppsx` opened from the command line or the file association starts the slide show (#862) |
|
||||
| 2026-10-15 | minor | `.thmx` row: Browse for Themes reads a theme's colours, fonts and effects; a `.pptx`/`.potx` brings its slide master and layouts (#807) |
|
||||
| 2026-10-14 | minor | Community PR wave folded: one row each for `.pptx` (seven owned corpus files), outline, 3-D models, embedded fonts, text, effects, 3-D (extrusion sides draw now), SmartArt (written as a diagram) and VBA (tags and custom XML are kept) |
|
||||
| 2026-10-12 | minor | SmartArt import lays each shape's text out on its `dsp:txXfrm` text rectangle instead of the shape box |
|
||||
| 2026-10-13 | minor | EMF/WMF rectangle clip regions limit later drawing; EMF+ records remain |
|
||||
|
||||
+7
-4
@@ -235,7 +235,8 @@ and starts at that effect. The pane has an advanced timeline with draggable bars
|
||||
### Edit points, text warp, 3-D
|
||||
- **Evidence:** checklist rows "Edit points" (P1 partial: Edit Points converts a shape to a
|
||||
freeform, vertices drag, Ctrl/Cmd-click adds or deletes, smooth/straight/corner points and Bézier
|
||||
handles; no Straight/Curved Segment or Open/Close Path), "Text warp transforms" (partial: all 40
|
||||
handles, Straight/Curved Segment on a segment and Open/Close Path on a point; the menu has no
|
||||
Delete Segment or Add Point), "Text warp transforms" (partial: all 40
|
||||
`prstTxWarp` presets render, their adjusts round-trip, Shape Format ▸ Text Effects ▸ Transform
|
||||
applies one with `format.textWarp`, and a selected warp shows a yellow handle per adjust that
|
||||
dragging sets; arc/circle placement can differ from PowerPoint by 1–3 % of the path length),
|
||||
@@ -245,8 +246,8 @@ and starts at that effect. The pane has an advanced timeline with draggable bars
|
||||
shade bevels and sides approximately (fitted to PowerPoint for three-point/matte only); contours
|
||||
draw as a band round the face outline (not round the extruded silhouette); Format Shape ▸
|
||||
Effects has 3-D Format and 3-D Rotation).
|
||||
- **Estimate:** 1 h (edit points: segment types, open/close path), 1–2 h (fitting the other rigs
|
||||
and materials to PowerPoint renders).
|
||||
- **Estimate:** 1 h (edit points: Delete Segment and Add Point in the menu), 1–2 h (fitting the
|
||||
other rigs and materials to PowerPoint renders).
|
||||
|
||||
### Proofing: spelling, thesaurus, AutoCorrect
|
||||
- **Evidence:** `review.spelling` checks with Hunspell-format dictionaries the system already has
|
||||
@@ -276,7 +277,7 @@ Zoom: live thumbnails, the zoom-in animation and the native `p166:sldZm` element
|
||||
artistic effects (3–6 h for the other 15 of 22; 7 approximated and round-tripped), remove
|
||||
background (6–10 h; needs a model or a classical matte), insert screenshot (2–3 h), record
|
||||
audio/screen and record slide show (8–12 h), compare presentations beyond slide-level accept
|
||||
(reject, per-object accept, markers on the slide: 3–5 h), password protection (4–6 h, ECMA-376
|
||||
and reject (per-object accept and reject, markers on the slide: 2–4 h), password protection (4–6 h, ECMA-376
|
||||
agile encryption), ink to text and math beyond Ink to Shape (6–10 h), 3-D models and stock content
|
||||
(6–10 h; Insert › Icons places a Lucide stroke set).
|
||||
|
||||
@@ -364,6 +365,8 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
| 2026-10-15 | trivial | Native saves retain chart package parts and relationships, including charts in groups; edited chart data still does not export a stale source workbook. Chart editing gaps and scores remain unchanged |
|
||||
| 2026-10-15 | trivial | PPTX trigger sequences keep later On Click steps instead of converting them to After Previous; mixed starts, independent buttons and paragraph builds have synthetic round-trip regressions |
|
||||
| 2026-10-16 | trivial | AutoFormat superscripts ordinals (1st, 22nd) as you type, with an AutoCorrect option to turn it off; automatic bulleted lists remain |
|
||||
| 2026-10-15 | minor | Edit Points: Straight/Curved Segment on a right-clicked segment and Open/Close Path on a point (`shape.segmentType`, `shape.openPath`, `shape.closePath`); left: Delete Segment and Add Point in the menu |
|
||||
| 2026-10-15 | trivial | Compare: slide-level Reject and Reject All landed; the entry keeps per-object accept and reject and the slide markers |
|
||||
| 2026-10-14 | trivial | Fixed #718: remote MCP no longer accepts unrelated or buffered stale success; loopback regressions cover invalid envelopes, incomplete and oversized frames |
|
||||
| 2026-10-14 | minor | Community PR wave folded: one entry each for the soak tests (engine and UI), the PPTX corpus (seven owned corpus files), edit points / text warp / 3-D, proofing (Hunspell spelling, thesaurus, AutoCorrect), the smaller missing rows, dialogs (41 vs ~60; Print and Insert Object left) and pen/trackpad input; ranked rows 3, 4 and 10 de-duplicated (comment threads #70 and the pane timeline are done); the fixed command-palette entry (#79) removed |
|
||||
| 2026-10-13 | minor | EMF/WMF rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ records remain |
|
||||
|
||||
+1
-1
@@ -99,6 +99,6 @@ A tool refuses an argument that is not in its schema (`insert_chart: unknown arg
|
||||
Useful commands for building decks: `slide.new {layout, title, body}`, `shape.insert {preset,
|
||||
rect, text}`, `shape.connect {from, to, preset}`, `shape.freeform {points, closed, smooth}`,
|
||||
`shape.merge {op, ids}`, `format.*`, `design.theme`, `transition.set`, `animation.set`,
|
||||
`chart.*`, `table.*`, `file.export {path, layout}` (PDF) or `{path, width, secondsPerSlide, loop}`
|
||||
`chart.*` (`chart.options {textColor}` colours a chart's title, axes, legend and labels), `table.*`, `file.export {path, layout}` (PDF) or `{path, width, secondsPerSlide, loop}`
|
||||
(GIF). `deckcraft-cli describe COMMAND` prints any
|
||||
command's parameters.
|
||||
|
||||
@@ -9,12 +9,12 @@ The summary between `<!-- SUMMARY -->
|
||||
|---|---|---|---|---|---|
|
||||
| P0 | 72 | 61 | 11 | 0 | 92% |
|
||||
| P1 | 65 | 45 | 16 | 4 | 82% |
|
||||
| P2 | 54 | 17 | 25 | 12 | 55% |
|
||||
| P2 | 54 | 18 | 24 | 12 | 56% |
|
||||
|
||||
| Area | Parity |
|
||||
|---|---|
|
||||
| Application shell | 81% |
|
||||
| Files | 75% |
|
||||
| Files | 76% |
|
||||
| Slides | 92% |
|
||||
| Views | 98% |
|
||||
| Masters & themes | 92% |
|
||||
@@ -64,7 +64,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
||||
| Files | Document properties | P2 | D |
|
||||
| Files | Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending) | P0 | P |
|
||||
| Files | Export slides as PNG/JPEG | P0 | D |
|
||||
| Files | Export outline / RTF | P2 | P |
|
||||
| Files | Export outline / RTF | P2 | D |
|
||||
| Files | Export video (MP4) / animated GIF (GIF: one frame per slide, no transitions; no video) | P2 | P |
|
||||
| Files | Print (slides, handouts, notes, outline) | P1 | M |
|
||||
| Files | Password protection | P2 | M |
|
||||
|
||||
+4
-1
@@ -88,12 +88,13 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
||||
| Nudge (arrows), fine nudge (Option+arrows) | yes | yes |
|
||||
| Shift-constrained move, Option-drag to duplicate | yes | yes |
|
||||
| Connector glue and rerouting | yes | yes |
|
||||
| Edit points on freeforms | yes | partial: Edit Shape ▸ Edit Points turns any shape into a freeform; drag vertices and their Bézier handles, Ctrl/Cmd-click to add or delete, right-click for Smooth/Straight/Corner Point and Delete Point; no Straight/Curved Segment or Open/Close Path yet |
|
||||
| Edit points on freeforms | yes | partial: Edit Shape ▸ Edit Points turns any shape into a freeform; drag vertices and their Bézier handles, Ctrl/Cmd-click to add or delete, right-click a point for Smooth/Straight/Corner Point, Delete Point and Open/Close Path, or a segment for Straight/Curved Segment; no Delete Segment or Add Point in the menu yet |
|
||||
| WordArt adjust handles | yellow handles on transformed text | yes, one per adjust when the text has a warp; dragging sets that guide value |
|
||||
| Crop handles, crop to shape, aspect ratio | yes | partial |
|
||||
| Text caret, selection, double/triple click, drag-select | yes | yes |
|
||||
| Exact size/position, lock aspect | yes | yes — Size and Position dialog (height, width, rotation, scale %, lock aspect, position from the top-left), one undo step; ribbon spinners still resize one side at a time |
|
||||
| Drag the frame of a text box being edited to move it | yes | yes |
|
||||
| Click the frame of a text box or placeholder to select it whole (then move, duplicate or delete it) | yes | yes; a click inside takes the caret |
|
||||
| Table cells: caret and selection in the cell, click into another cell, drag row and column borders | yes | yes; no cell-range selection by dragging across cells |
|
||||
| Context menus on shapes, slides, text, thumbnails | yes | yes, shorter; a right click selects the shape under the pointer first |
|
||||
| Group fill and outline | formats the members | Shape Fill and Shape Outline reach nested members in one undo step; a transparency-only fill edit retains each member's colour (#1079) |
|
||||
@@ -172,6 +173,7 @@ consistency is open (#73, #56).
|
||||
| 2026-10-23 | minor | Shape Fill and Shape Outline (Home and Shape Format): the button applies the last colour picked from its menu, Accent 1 and Text 1 until one is picked. Line weight and dashes are not changed by the button; Font Color and Highlight stay as they were (#1080) |
|
||||
| 2026-10-22 | trivial | Group fill and outline commands reach members, including nested groups; selection and single-step undo are preserved (#1079). The existing Arrange / Group checklist score stays D |
|
||||
| 2026-10-16 | minor | Export › Change File Type › PNG and the Export… dialog offer a transparent background (#784) |
|
||||
| 2026-10-15 | minor | Edit Points: the right-click menu has Straight/Curved Segment on a segment and Open/Close Path on a point |
|
||||
| 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing |
|
||||
| 2026-10-14 | minor | Community PR wave folded: one row each for Format Shape, Dialogs (41, listed from `dialogs.rs`), Animation Pane (timeline is in), window, Quick Access Toolbar, Format Background, rulers, image paste, context menus, exact size and edit points; estimates re-totalled (~26–46 h) |
|
||||
| 2026-10-13 | trivial | Eyedropper in the colour menus (#367) |
|
||||
@@ -199,6 +201,7 @@ consistency is open (#73, #56).
|
||||
| 2026-10-12 | minor | Dragging the frame of a text box being edited moves it, as in PowerPoint |
|
||||
| 2026-10-12 | minor | Table cells: caret and selection shown in the edited cell, clicks place the caret in the clicked cell, row and column borders drag to resize |
|
||||
| 2026-10-14 | minor | Format Background pane: solid transparency, picture transparency, tile, offset, scale, alignment, mirror, and Apply to All copying a picture. Not a new dialog; the dialog count stays 27 |
|
||||
| 2026-10-15 | minor | Clicking the frame of a text box or placeholder selects the box whole instead of starting text entry (#787) |
|
||||
| 2026-10-13 | trivial | Editor slide rasters sit on the physical pixel grid (#184) |
|
||||
| 2026-10-14 | minor | Copy and paste rich text (HTML: bold, italic, underline) with other apps |
|
||||
| 2026-10-13 | minor | Animations ▸ Preview and Animation Pane ▸ Play All play the current slide on the editor canvas |
|
||||
|
||||
Reference in New Issue
Block a user