mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 18:46:57 -06:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ecb13649e | ||
|
|
fc1f475e50 | ||
|
|
3ef6433309 | ||
|
|
286a01eaae | ||
|
|
f665887094 |
Generated
+2
-2
@@ -816,7 +816,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2429,7 +2429,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bumpalo",
|
||||
|
||||
+2
-2
@@ -60,7 +60,7 @@ vello_cpu = "0.2"
|
||||
vello_common = { version = "0.2", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4", version = "=0.1.0", default-features = false }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
i_overlay = "9"
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
@@ -103,4 +103,4 @@ codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.8.2" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.8.2" }
|
||||
|
||||
-21
@@ -134,7 +134,6 @@ 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) |
|
||||
@@ -146,10 +145,7 @@ 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) |
|
||||
@@ -162,26 +158,14 @@ 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. |
|
||||
@@ -281,7 +265,6 @@ 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 |
|
||||
@@ -293,10 +276,6 @@ 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 |
|
||||
|
||||
@@ -407,7 +407,7 @@ fn frame_paths(out: &str, ts: &[f64]) -> Result<Vec<String>, String> {
|
||||
return Ok(vec![out.to_string()]);
|
||||
}
|
||||
let p = std::path::Path::new(out);
|
||||
let stem = p.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("frame");
|
||||
let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("frame");
|
||||
let mut paths: Vec<String> = Vec::with_capacity(ts.len());
|
||||
for t in ts {
|
||||
let path = p.with_file_name(format!("{stem}-t{t:.3}.png")).to_string_lossy().into_owned();
|
||||
|
||||
@@ -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/deckcraft.ico" />
|
||||
<link data-trunk rel="icon" href="../../assets/app-icon/hicolor/32x32/apps/ai.storyteller.deckcraft.png" />
|
||||
<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; }
|
||||
|
||||
@@ -41,9 +41,8 @@ pub fn poll(inbox: &Inbox, app: &mut SlideApp, ctx: &egui::Context) {
|
||||
Event::Open(paths) => {
|
||||
for p in paths.iter().filter_map(|p| p.to_str()) {
|
||||
if let Err(e) = app.open_path(p) {
|
||||
let safe_p = p.replace(['\r', '\n'], " ");
|
||||
log::error!("{safe_p}: {e}");
|
||||
app.set_status(format!("Couldn't open {safe_p}: {e}"));
|
||||
log::error!("{p}: {e}");
|
||||
app.set_status(format!("Couldn't open {p}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,3 @@ real_name = "Ryan C. Shoemaker"
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.GhostMitra]
|
||||
sync_github_name = true
|
||||
|
||||
+9
-19
@@ -1,10 +1,10 @@
|
||||
//! Slide show navigation: click steps, slide order (hidden slides, custom shows, ranges), loop
|
||||
//! and automatic advance.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use deckcraft_model::Presentation;
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
use deckcraft_model::{Presentation, SlideId};
|
||||
|
||||
use crate::Timeline;
|
||||
use crate::interact::SlideRun;
|
||||
@@ -83,25 +83,12 @@ pub struct ShowState {
|
||||
pub zoom: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
/// Index of each slide by id (the first slide wins a duplicate id), so resolving a custom show
|
||||
/// costs the sum of its two lists, not their product.
|
||||
fn slide_indices(pres: &Presentation) -> HashMap<SlideId, usize> {
|
||||
let mut map = HashMap::with_capacity(pres.slides.len());
|
||||
for (i, s) in pres.slides.iter().enumerate() {
|
||||
map.entry(s.id).or_insert(i);
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Slide indices the show visits in order: the custom show or range from Set Up Slide Show, minus
|
||||
/// hidden slides.
|
||||
pub fn show_order(pres: &Presentation) -> Vec<usize> {
|
||||
let n = pres.slides.len();
|
||||
let base: Vec<usize> = match pres.show.custom_show.as_ref().and_then(|name| pres.custom_shows.iter().find(|c| &c.name == name)) {
|
||||
Some(cs) => {
|
||||
let index = slide_indices(pres);
|
||||
cs.slides.iter().filter_map(|id| index.get(id).copied()).collect()
|
||||
}
|
||||
Some(cs) => cs.slides.iter().filter_map(|id| pres.slides.iter().position(|s| s.id == *id)).collect(),
|
||||
None => match pres.show.range {
|
||||
Some((a, b)) => {
|
||||
let a = (a.max(1) as usize).saturating_sub(1);
|
||||
@@ -117,9 +104,12 @@ pub fn show_order(pres: &Presentation) -> Vec<usize> {
|
||||
/// Visible slides of custom show `name`, in that show's order.
|
||||
fn custom_indices(pres: &Presentation, name: &str) -> Option<Vec<usize>> {
|
||||
let cs = pres.custom_shows.iter().find(|c| c.name == name)?;
|
||||
let index = slide_indices(pres);
|
||||
let order: Vec<usize> =
|
||||
cs.slides.iter().filter_map(|id| index.get(id).copied()).filter(|&i| pres.slides.get(i).is_some_and(|s| !s.hidden)).collect();
|
||||
let order: Vec<usize> = cs
|
||||
.slides
|
||||
.iter()
|
||||
.filter_map(|id| pres.slides.iter().position(|s| s.id == *id))
|
||||
.filter(|&i| pres.slides.get(i).is_some_and(|s| !s.hidden))
|
||||
.collect();
|
||||
if order.is_empty() { None } else { Some(order) }
|
||||
}
|
||||
|
||||
|
||||
@@ -1082,53 +1082,3 @@ fn show_returns_to_the_zoom_slide() {
|
||||
s.goto(&p, 4);
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_show_resolves_duplicate_ids_to_the_first_slide() {
|
||||
let mut p = pres(3);
|
||||
Arc::make_mut(&mut p.slides[2]).id = p.slides[0].id;
|
||||
p.custom_shows = vec![CustomShow { name: "Dup".into(), slides: vec![p.slides[0].id, SlideId(1001), SlideId(424_242)] }];
|
||||
p.show.custom_show = Some("Dup".into());
|
||||
assert_eq!(show_order(&p), vec![0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_show_with_many_entries_resolves_quickly() {
|
||||
let mut p = pres(20_000);
|
||||
let ids: Vec<SlideId> = p.slides.iter().map(|s| s.id).collect();
|
||||
p.custom_shows = vec![CustomShow { name: "All".into(), slides: ids.iter().rev().copied().collect() }];
|
||||
p.show.custom_show = Some("All".into());
|
||||
let t = std::time::Instant::now();
|
||||
let order = show_order(&p);
|
||||
assert_eq!(order.len(), 20_000);
|
||||
assert_eq!(order[0], 19_999);
|
||||
assert!(t.elapsed().as_secs() < 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_targets_the_first_shape_with_a_duplicate_id() {
|
||||
let mut s = slide_with(vec![anim(7, AnimClass::Entrance, "fade", AnimStart::OnClick)]);
|
||||
s.shapes[0] = shape(7, "first");
|
||||
s.shapes[1] = shape(7, "second");
|
||||
s.shapes[0].xfrm = Some(Xfrm::new(10.0, 10.0, 20.0, 20.0));
|
||||
let tl = Timeline::new(&s, W, H);
|
||||
assert_eq!(tl.effects().len(), 1);
|
||||
assert_eq!(tl.steps(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_by_paragraph_animations_on_one_shape_share_paragraph_targets() {
|
||||
let mut s = slide_with(vec![]);
|
||||
s.shapes[0].text = Some(TextBody::from_text("one\ntwo\nthree"));
|
||||
s.animations = (0..2_000)
|
||||
.map(|_| {
|
||||
let mut a = anim(1, AnimClass::Path, "customPath", AnimStart::OnClick);
|
||||
a.text_build = TextBuild::ByParagraph;
|
||||
a.path = Some("M 0 0 L 0.5 0.5 E".into());
|
||||
a
|
||||
})
|
||||
.collect();
|
||||
let tl = Timeline::new(&s, W, H);
|
||||
assert_eq!(tl.effects().len(), 6_000);
|
||||
assert_eq!(tl.paragraph_targets(ShapeId(1)), vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
+27
-56
@@ -1,11 +1,10 @@
|
||||
//! A slide's animation sequence: click steps, effect start times and per-shape state over time.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
|
||||
use deckcraft_model::anim::{BuildRole, TextBuild, paragraph_build_roles};
|
||||
use deckcraft_model::anim::TextBuild;
|
||||
use deckcraft_model::style::Fill;
|
||||
use deckcraft_model::{AnimClass, AnimStart, Animation, ColorScheme, Rgba, SchemeSlot, Shape, ShapeId, Slide, Xfrm};
|
||||
use deckcraft_model::{AnimClass, AnimStart, Animation, ColorScheme, Rgba, SchemeSlot, ShapeId, Slide, Xfrm};
|
||||
|
||||
use crate::AnimState;
|
||||
use crate::effects::{EffectSpec, exit_base, motion_path};
|
||||
@@ -13,6 +12,7 @@ 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.
|
||||
@@ -86,9 +86,7 @@ enum After {
|
||||
#[derive(Clone, Debug)]
|
||||
struct Effect {
|
||||
timing: EffectTiming,
|
||||
/// Shared by every paragraph effect of one animation, so a large motion path is not copied
|
||||
/// per paragraph.
|
||||
spec: Rc<EffectSpec>,
|
||||
spec: EffectSpec,
|
||||
after: After,
|
||||
}
|
||||
|
||||
@@ -165,11 +163,6 @@ impl Timeline {
|
||||
let accent = scheme.map(|s| s.get(SchemeSlot::Accent1)).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
|
||||
|
||||
let mut tl = Timeline::default();
|
||||
// Every shape by id, built once: resolving each animation's target by tree search would
|
||||
// cost shapes x animations.
|
||||
let shapes = shape_index(&slide.shapes);
|
||||
// Paragraph targets of each shape, worked out for its first by-paragraph animation only.
|
||||
let mut para_cache: HashMap<ShapeId, Rc<Vec<Option<usize>>>> = HashMap::new();
|
||||
// Group the animations by sequence in one pass, sequences in order of first appearance
|
||||
// (main first), so many distinct triggers cost linear work, not a scan per trigger.
|
||||
let mut seqs: Vec<(Sequence, Vec<usize>)> = vec![(Sequence::Main, Vec::new())];
|
||||
@@ -198,38 +191,34 @@ impl Timeline {
|
||||
if tl.effects.len() >= MAX_EFFECTS {
|
||||
break;
|
||||
}
|
||||
let shape = shapes.get(&a.shape).copied();
|
||||
let shape = slide.shape(a.shape);
|
||||
// Paragraph targets of this animation.
|
||||
let paras: Rc<Vec<Option<usize>>> = match (a.paragraph, a.text_build) {
|
||||
(Some(p), _) => Rc::new(vec![Some(p as usize)]),
|
||||
let paras: Vec<Option<usize>> = match (a.paragraph, a.text_build) {
|
||||
(Some(p), _) => vec![Some(p as usize)],
|
||||
(None, TextBuild::ByParagraph) => {
|
||||
let cached = para_cache.entry(a.shape).or_insert_with(|| {
|
||||
let mut v: Vec<Option<usize>> = vec![];
|
||||
if let Some(t) = shape.and_then(|s| s.text.as_ref()) {
|
||||
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 => {}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
if v.is_empty() {
|
||||
v.push(None)
|
||||
}
|
||||
Rc::new(v)
|
||||
});
|
||||
Rc::clone(cached)
|
||||
}
|
||||
if v.is_empty() { vec![None] } else { v }
|
||||
}
|
||||
_ => Rc::new(vec![None]),
|
||||
_ => vec![None],
|
||||
};
|
||||
let xf = xfrm_of(a.shape).or_else(|| shape.and_then(|s| s.xfrm)).unwrap_or(Xfrm::new(sw / 4.0, sh / 4.0, sw / 2.0, sh / 2.0));
|
||||
let xf = sanitize_xfrm(xf, sw, sh);
|
||||
let spec = Rc::new(build_spec(a, xf, sw, sh, &resolve));
|
||||
let spec = build_spec(a, xf, sw, sh, &resolve);
|
||||
let dur = secs(a.duration_ms);
|
||||
let delay = secs(a.delay_ms);
|
||||
let until_next_click = a.repeat == u32::MAX;
|
||||
@@ -298,7 +287,7 @@ impl Timeline {
|
||||
span,
|
||||
class: a.class,
|
||||
},
|
||||
spec: Rc::clone(&spec),
|
||||
spec: spec.clone(),
|
||||
after: after.clone(),
|
||||
});
|
||||
}
|
||||
@@ -309,13 +298,12 @@ impl Timeline {
|
||||
}
|
||||
}
|
||||
// Base colours of animated shapes.
|
||||
let mut colored: HashSet<ShapeId> = HashSet::new();
|
||||
for e in &tl.effects {
|
||||
let id = e.timing.shape;
|
||||
if !colored.insert(id) {
|
||||
if tl.colors.iter().any(|(s, _)| *s == id) {
|
||||
continue;
|
||||
}
|
||||
let c = match shapes.get(&id).copied().and_then(|s| s.fill.as_ref()) {
|
||||
let c = match slide.shape(id).and_then(|s| s.fill.as_ref()) {
|
||||
Some(Fill::Solid { color }) => resolve(color),
|
||||
_ => accent,
|
||||
};
|
||||
@@ -548,23 +536,6 @@ fn status(e: &EffectTiming, step: usize, t: f64) -> Status {
|
||||
Status::Active(p.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// Shapes by id in the order `Slide::shape` searches (a shape before its children, groups at most
|
||||
/// 64 deep); the first shape with an id wins a duplicate.
|
||||
fn shape_index(shapes: &[Shape]) -> HashMap<ShapeId, &Shape> {
|
||||
fn walk<'a>(shapes: &'a [Shape], depth: usize, out: &mut HashMap<ShapeId, &'a Shape>) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for s in shapes {
|
||||
out.entry(s.id).or_insert(s);
|
||||
walk(s.children(), depth + 1, out);
|
||||
}
|
||||
}
|
||||
let mut out = HashMap::new();
|
||||
walk(shapes, 0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn build_spec(a: &Animation, xf: Xfrm, sw: f64, sh: f64, resolve: &dyn Fn(&deckcraft_model::ColorRef) -> Rgba) -> EffectSpec {
|
||||
let effect = if a.class == AnimClass::Exit { exit_base(&a.effect).to_string() } else { a.effect.clone() };
|
||||
let path = if a.class == AnimClass::Path { Some(motion_path(&a.effect, &a.option, a.path.as_deref())) } else { None };
|
||||
|
||||
@@ -9,10 +9,7 @@ use std::f64::consts::PI;
|
||||
|
||||
use crate::easing::ease;
|
||||
use crate::transition::{FULL, Layer, R, Source};
|
||||
use crate::transition_geom::{
|
||||
Cam, V3, boundary, card, center, clip, dir, edge, ein, eout, facing_shade, fan, field, hash64, moved, offset, poly_layers, pow_inout, q2,
|
||||
rect_poly, rnd, rot, rot_quad, scale_rect, scaled, soft_rect, ss, v3, win,
|
||||
};
|
||||
use crate::transition_geom::*;
|
||||
|
||||
use Source::{Black, New, NewBack, NewContent, Old, OldBack, OldContent, White};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! CDEF (7.15).
|
||||
|
||||
use crate::frame::{FrameBuf, Plane};
|
||||
use crate::spec_tables::{CDEF_DIRECTIONS, CDEF_PRI_TAPS, CDEF_SEC_TAPS, CDEF_UV_DIR, DIV_TABLE};
|
||||
use crate::spec_tables::*;
|
||||
use crate::state::FrameState;
|
||||
|
||||
/// Run CDEF on `fs.cur`; returns the CdefFrame, or None when no block is filtered (CdefFrame
|
||||
@@ -242,14 +242,14 @@ fn filter_rows<const W: usize>(
|
||||
}
|
||||
for i in 0..h {
|
||||
let ci = ((i + 2) * PAD_STRIDE + 2) as isize;
|
||||
let Some(&x) = b16.get(ci as usize..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
let Some(&x) = b16.get(ci as usize..).and_then(|r| r.first_chunk::<W>()) else {
|
||||
return;
|
||||
};
|
||||
let (mut sum, mut max, mut min) = ([0i16; W], x, x);
|
||||
for &(off, weight, thr, adj) in taps {
|
||||
let (weight, thr) = (weight as i16, thr as i16);
|
||||
let start = (ci + off) as usize;
|
||||
let Some(p) = b16.get(start..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
let Some(p) = b16.get(start..).and_then(|r| r.first_chunk::<W>()) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
|
||||
+1
-21
@@ -1,26 +1,6 @@
|
||||
//! The adaptive CDF arrays (spec 6.8.2 init_non_coeff_cdfs / init_coeff_cdfs, load_cdfs).
|
||||
|
||||
use crate::spec_tables::{
|
||||
DEFAULT_ANGLE_DELTA_CDF, DEFAULT_CFL_ALPHA_CDF, DEFAULT_CFL_SIGN_CDF, DEFAULT_COEFF_BASE_CDF, DEFAULT_COEFF_BASE_EOB_CDF, DEFAULT_COEFF_BR_CDF,
|
||||
DEFAULT_COMP_BWD_REF_CDF, DEFAULT_COMP_GROUP_IDX_CDF, DEFAULT_COMP_MODE_CDF, DEFAULT_COMP_REF_CDF, DEFAULT_COMP_REF_TYPE_CDF,
|
||||
DEFAULT_COMPOUND_IDX_CDF, DEFAULT_COMPOUND_MODE_CDF, DEFAULT_COMPOUND_TYPE_CDF, DEFAULT_DC_SIGN_CDF, DEFAULT_DELTA_LF_CDF, DEFAULT_DELTA_Q_CDF,
|
||||
DEFAULT_DRL_MODE_CDF, DEFAULT_EOB_EXTRA_CDF, DEFAULT_EOB_PT_16_CDF, DEFAULT_EOB_PT_32_CDF, DEFAULT_EOB_PT_64_CDF, DEFAULT_EOB_PT_128_CDF,
|
||||
DEFAULT_EOB_PT_256_CDF, DEFAULT_EOB_PT_512_CDF, DEFAULT_EOB_PT_1024_CDF, DEFAULT_FILTER_INTRA_CDF, DEFAULT_FILTER_INTRA_MODE_CDF,
|
||||
DEFAULT_INTER_INTRA_CDF, DEFAULT_INTER_INTRA_MODE_CDF, DEFAULT_INTER_TX_TYPE_SET1_CDF, DEFAULT_INTER_TX_TYPE_SET2_CDF,
|
||||
DEFAULT_INTER_TX_TYPE_SET3_CDF, DEFAULT_INTERP_FILTER_CDF, DEFAULT_INTRA_TX_TYPE_SET1_CDF, DEFAULT_INTRA_TX_TYPE_SET2_CDF, DEFAULT_INTRABC_CDF,
|
||||
DEFAULT_IS_INTER_CDF, DEFAULT_MOTION_MODE_CDF, DEFAULT_MV_BIT_CDF, DEFAULT_MV_CLASS_CDF, DEFAULT_MV_CLASS0_BIT_CDF, DEFAULT_MV_CLASS0_FR_CDF,
|
||||
DEFAULT_MV_CLASS0_HP_CDF, DEFAULT_MV_FR_CDF, DEFAULT_MV_HP_CDF, DEFAULT_MV_JOINT_CDF, DEFAULT_MV_SIGN_CDF, DEFAULT_NEW_MV_CDF,
|
||||
DEFAULT_PALETTE_SIZE_2_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_2_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_3_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_3_Y_COLOR_CDF,
|
||||
DEFAULT_PALETTE_SIZE_4_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_4_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_5_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_5_Y_COLOR_CDF,
|
||||
DEFAULT_PALETTE_SIZE_6_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_6_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_7_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_7_Y_COLOR_CDF,
|
||||
DEFAULT_PALETTE_SIZE_8_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_8_Y_COLOR_CDF, DEFAULT_PALETTE_UV_MODE_CDF, DEFAULT_PALETTE_UV_SIZE_CDF,
|
||||
DEFAULT_PALETTE_Y_MODE_CDF, DEFAULT_PALETTE_Y_SIZE_CDF, DEFAULT_PARTITION_W8_CDF, DEFAULT_PARTITION_W16_CDF, DEFAULT_PARTITION_W32_CDF,
|
||||
DEFAULT_PARTITION_W64_CDF, DEFAULT_PARTITION_W128_CDF, DEFAULT_REF_MV_CDF, DEFAULT_RESTORATION_TYPE_CDF, DEFAULT_SEGMENT_ID_CDF,
|
||||
DEFAULT_SEGMENT_ID_PREDICTED_CDF, DEFAULT_SINGLE_REF_CDF, DEFAULT_SKIP_CDF, DEFAULT_SKIP_MODE_CDF, DEFAULT_TX_8X8_CDF, DEFAULT_TX_16X16_CDF,
|
||||
DEFAULT_TX_32X32_CDF, DEFAULT_TX_64X64_CDF, DEFAULT_TXB_SKIP_CDF, DEFAULT_TXFM_SPLIT_CDF, DEFAULT_UNI_COMP_REF_CDF, DEFAULT_USE_OBMC_CDF,
|
||||
DEFAULT_USE_SGRPROJ_CDF, DEFAULT_USE_WIENER_CDF, DEFAULT_UV_MODE_CFL_ALLOWED_CDF, DEFAULT_UV_MODE_CFL_NOT_ALLOWED_CDF, DEFAULT_WEDGE_INDEX_CDF,
|
||||
DEFAULT_WEDGE_INTER_INTRA_CDF, DEFAULT_Y_MODE_CDF, DEFAULT_ZERO_MV_CDF, FRAME_LF_COUNT, MV_CONTEXTS,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
|
||||
/// Every CDF array listed by init_non_coeff_cdfs and init_coeff_cdfs. Each innermost array holds
|
||||
/// N cumulative values (the last is 32768) plus the adaptation counter.
|
||||
|
||||
@@ -17,11 +17,7 @@ use crate::cdf::Cdfs;
|
||||
use crate::frame::FrameBuf;
|
||||
use crate::header::{FilmGrainParams, FrameHeader, HeaderState, LoopFilterDeltas, RefInfo, SegmentationFeatures, SequenceHeader, default_gm_params};
|
||||
use crate::par::Pool;
|
||||
use crate::spec_tables::{
|
||||
ALTREF_FRAME, ALTREF2_FRAME, BWDREF_FRAME, DEFAULT_INTRA_FRAME_Y_MODE_CDF, DIV_MULT, GOLDEN_FRAME, INTRA_FRAME, INTRA_ONLY_FRAME, KEY_FRAME,
|
||||
LAST_FRAME, LAST2_FRAME, MAX_FRAME_DISTANCE, MAX_OFFSET_HEIGHT, MAX_OFFSET_WIDTH, MFMV_STACK_SIZE, NUM_REF_FRAMES, PRIMARY_REF_NONE,
|
||||
REFMVS_LIMIT, REFS_PER_FRAME,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
use crate::state::{FrameShared, FrameState, TileState};
|
||||
use crate::stats::{DecodeStats, Stage, Timer};
|
||||
use crate::tile::TileDecoder;
|
||||
@@ -65,7 +61,7 @@ impl<T: Clone> Slot<T> {
|
||||
}
|
||||
|
||||
fn set(&self, v: Result<T>) {
|
||||
let mut g = self.value.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut g = self.value.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*g = Some(v);
|
||||
self.cv.notify_all();
|
||||
}
|
||||
@@ -75,12 +71,12 @@ impl<T: Clone> Slot<T> {
|
||||
}
|
||||
|
||||
fn wait(&self) -> Result<T> {
|
||||
let mut g = self.value.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut g = self.value.lock().unwrap_or_else(|e| e.into_inner());
|
||||
loop {
|
||||
if let Some(v) = g.as_ref() {
|
||||
return v.clone();
|
||||
}
|
||||
g = self.cv.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g = self.cv.wait(g).unwrap_or_else(|e| e.into_inner());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const POOL_MAX_SAMPLES: usize = 96 << 20;
|
||||
fn zeroed(len: usize) -> Vec<u16> {
|
||||
if len >= POOL_MIN_LEN {
|
||||
let found = {
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
pool.iter().position(|v| v.capacity() >= len && v.capacity() <= 2 * len).map(|i| pool.swap_remove(i))
|
||||
};
|
||||
if let Some(mut v) = found {
|
||||
@@ -52,8 +52,8 @@ impl Drop for Plane {
|
||||
fn drop(&mut self) {
|
||||
if self.data.capacity() >= POOL_MIN_LEN {
|
||||
let v = std::mem::take(&mut self.data);
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let kept: usize = pool.iter().map(Vec::capacity).sum();
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let kept: usize = pool.iter().map(|b| b.capacity()).sum();
|
||||
if kept + v.capacity() <= POOL_MAX_SAMPLES {
|
||||
pool.push(v);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
//! Sequence header (5.5) and uncompressed frame header (5.9) parsing.
|
||||
|
||||
use crate::bits::BitReader;
|
||||
use crate::spec_tables::{
|
||||
AFFINE, ALTREF_FRAME, ALTREF2_FRAME, BWDREF_FRAME, GM_ABS_ALPHA_BITS, GM_ABS_TRANS_BITS, GM_ABS_TRANS_ONLY_BITS, GM_ALPHA_PREC_BITS,
|
||||
GM_TRANS_ONLY_PREC_BITS, GM_TRANS_PREC_BITS, GOLDEN_FRAME, IDENTITY, INTER_FRAME, INTRA_ONLY_FRAME, KEY_FRAME, LAST_FRAME, MAX_SEGMENTS,
|
||||
MAX_TILE_AREA, MAX_TILE_COLS, MAX_TILE_ROWS, MAX_TILE_WIDTH, NUM_REF_FRAMES, ONLY_4X4, PRIMARY_REF_NONE, REF_FRAME_LIST, REFS_PER_FRAME,
|
||||
RESTORATION_TILESIZE_MAX, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_SWITCHABLE, RESTORE_WIENER, ROTZOOM, SEG_LVL_ALT_Q, SEG_LVL_MAX,
|
||||
SEG_LVL_REF_FRAME, SELECT_INTEGER_MV, SELECT_SCREEN_CONTENT_TOOLS, SUPERRES_DENOM_BITS, SUPERRES_DENOM_MIN, SUPERRES_NUM, SWITCH_FRAME,
|
||||
TOTAL_REFS_PER_FRAME, TRANSLATION, TX_MODE_LARGEST, TX_MODE_SELECT, WARPEDMODEL_PREC_BITS,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub const EIGHTTAP: u8 = 0;
|
||||
|
||||
+3
-12
@@ -5,16 +5,7 @@ use std::sync::OnceLock;
|
||||
|
||||
use crate::frame::Plane;
|
||||
use crate::mvpred::round2_signed64;
|
||||
use crate::spec_tables::{
|
||||
BLOCK_8X8, BLOCK_SIZES, COMPOUND_AVERAGE, COMPOUND_DIFFWTD, COMPOUND_DISTANCE, COMPOUND_INTRA, COMPOUND_WEDGE, DIV_LUT, DIV_LUT_BITS,
|
||||
DIV_LUT_PREC_BITS, EIGHTTAP, EIGHTTAP_SHARP, EIGHTTAP_SMOOTH, FILTER_BITS, GLOBAL_GLOBALMV, GLOBALMV, II_H_PRED, II_SMOOTH_PRED, II_V_PRED,
|
||||
II_WEIGHTS_1D, INTRA_FRAME, LAST_FRAME, LOCALWARP, LS_MV_MAX, MASK_MASTER_SIZE, MAX_FRAME_DISTANCE, MAX_SB_SIZE, MI_HEIGHT_LOG2, MI_WIDTH_LOG2,
|
||||
NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, OBMC, OBMC_MASK_2, OBMC_MASK_4, OBMC_MASK_8, OBMC_MASK_16, OBMC_MASK_32, QUANT_DIST_LOOKUP,
|
||||
QUANT_DIST_WEIGHT, REF_SCALE_SHIFT, SCALE_SUBPEL_BITS, SUBPEL_BITS, SUBPEL_FILTERS, SUBPEL_MASK, TRANSLATION, WARP_PARAM_REDUCE_BITS,
|
||||
WARPED_FILTERS, WARPEDDIFF_PREC_BITS, WARPEDMODEL_NONDIAGAFFINE_CLAMP, WARPEDMODEL_PREC_BITS, WARPEDMODEL_TRANS_CLAMP, WARPEDPIXEL_PREC_SHIFTS,
|
||||
WEDGE_BITS, WEDGE_CODEBOOK, WEDGE_HORIZONTAL, WEDGE_MASTER_OBLIQUE_EVEN, WEDGE_MASTER_OBLIQUE_ODD, WEDGE_MASTER_VERTICAL, WEDGE_OBLIQUE27,
|
||||
WEDGE_OBLIQUE63, WEDGE_OBLIQUE117, WEDGE_OBLIQUE153, WEDGE_TYPES, WEDGE_VERTICAL,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
use crate::tile::TileDecoder;
|
||||
|
||||
const PRED_STRIDE: usize = 128;
|
||||
@@ -687,7 +678,7 @@ fn hpass<const W: usize>(src: &[u16], hf: &[i16; 8], out: &mut [i32], half: i32,
|
||||
let mut acc = [half; W];
|
||||
for t in 0..8 {
|
||||
let f = hf[t] as i32;
|
||||
let Some(p) = src.get(t..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
let Some(p) = src.get(t..).and_then(|s| s.first_chunk::<W>()) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
@@ -708,7 +699,7 @@ fn vpass<const W: usize>(rows: &[i32], vf: &[i16; 8], out: &mut [i32], half: i32
|
||||
let mut acc = [half; W];
|
||||
for t in 0..8 {
|
||||
let f = vf[t] as i32;
|
||||
let Some(p) = rows.get(t * W..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
let Some(p) = rows.get(t * W..).and_then(|s| s.first_chunk::<W>()) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
|
||||
@@ -3,14 +3,7 @@
|
||||
use crate::Result;
|
||||
use crate::header::{EIGHTTAP, SWITCHABLE};
|
||||
use crate::mvpred::MvP;
|
||||
use crate::spec_tables::{
|
||||
ALTREF_FRAME, ALTREF2_FRAME, BLOCK_8X8, BLOCK_32X32, BLOCK_64X64, BLOCK_128X128, BWDREF_FRAME, CLASS0_SIZE, COMP_NEWMV_CTXS, COMPOUND_AVERAGE,
|
||||
COMPOUND_DIFFWTD, COMPOUND_DISTANCE, COMPOUND_INTRA, COMPOUND_MODE_CTX_MAP, COMPOUND_REFERENCE, COMPOUND_WEDGE, GLOBAL_GLOBALMV, GLOBALMV,
|
||||
GOLDEN_FRAME, INTRA_FRAME, INTRABC_DELAY_PIXELS, LAST_FRAME, LAST2_FRAME, LAST3_FRAME, LOCALWARP, MV_CLASS_0, MV_INTRABC_CONTEXT,
|
||||
MV_JOINT_HNZVNZ, MV_JOINT_HNZVZ, MV_JOINT_HZVNZ, NEAR_NEARMV, NEAR_NEWMV, NEAREST_NEARESTMV, NEAREST_NEWMV, NEARESTMV, NEARMV, NEW_NEARESTMV,
|
||||
NEW_NEARMV, NEW_NEWMV, NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, OBMC, REF_SCALE_SHIFT, SEG_LVL_GLOBALMV, SEG_LVL_REF_FRAME, SEG_LVL_SKIP,
|
||||
SIMPLE, SINGLE_REFERENCE, SIZE_GROUP, TRANSLATION, UNIDIR_COMP_REFERENCE, UV_CFL_PRED, WEDGE_BITS,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
use crate::tile::TileDecoder;
|
||||
|
||||
fn block_width(bs: usize) -> usize {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
//! Intra prediction (spec 7.11.2), palette prediction (7.11.4) and chroma from luma (7.11.5).
|
||||
|
||||
use crate::frame::Plane;
|
||||
use crate::spec_tables::{
|
||||
ANGLE_STEP, D67_PRED, DC_PRED, DR_INTRA_DERIVATIVE, INTRA_EDGE_KERNEL, INTRA_EDGE_TAPS, INTRA_FILTER_SCALE_BITS, INTRA_FILTER_TAPS,
|
||||
MODE_TO_ANGLE, SM_WEIGHTS_TX_4X4, SM_WEIGHTS_TX_8X8, SM_WEIGHTS_TX_16X16, SM_WEIGHTS_TX_32X32, SM_WEIGHTS_TX_64X64, SMOOTH_H_PRED, SMOOTH_PRED,
|
||||
SMOOTH_V_PRED, V_PRED,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
use crate::transform::round2;
|
||||
|
||||
/// Inputs of the intra prediction process that come from the block.
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
//! Motion vector prediction processes (spec 7.10): find_mv_stack, has_overlappable_candidates
|
||||
//! and find_warp_samples.
|
||||
|
||||
use crate::spec_tables::{
|
||||
GLOBAL_GLOBALMV, GLOBALMV, IDENTITY, INTRA_FRAME, LEAST_SQUARES_SAMPLES_MAX, MAX_REF_MV_STACK_SIZE, MV_BORDER, NEAR_NEWMV, NEAREST_NEWMV,
|
||||
NEW_NEARESTMV, NEW_NEARMV, NEW_NEWMV, NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, REF_CAT_LEVEL, TRANSLATION, WARPEDMODEL_PREC_BITS,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
use crate::tile::TileDecoder;
|
||||
|
||||
pub(crate) type MvP = [i32; 2];
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
//! In-loop post filters run by decode_frame_wrapup: loop filter (7.14), CDEF (7.15),
|
||||
//! super-resolution upscaling (7.16) and loop restoration (7.17).
|
||||
|
||||
use crate::spec_tables::{
|
||||
GLOBAL_GLOBALMV, GLOBALMV, INTRA_FRAME, MAX_LOOP_FILTER, NEARESTMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, SEG_LVL_ALT_LF_Y_V, TX_HEIGHT,
|
||||
TX_WIDTH,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
use crate::state::FrameState;
|
||||
use crate::stats::{DecodeStats, Stage, Timer};
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
//! Super-resolution upscaling (7.16) and loop restoration (7.17).
|
||||
|
||||
use crate::frame::{FrameBuf, Plane};
|
||||
use crate::spec_tables::{
|
||||
FILTER_BITS, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_WIENER, SGR_PARAMS, SGRPROJ_MTABLE_BITS, SGRPROJ_PRJ_BITS, SGRPROJ_RECIP_BITS,
|
||||
SGRPROJ_RST_BITS, SGRPROJ_SGR_BITS, SUPERRES_EXTRA_BITS, SUPERRES_FILTER_OFFSET, SUPERRES_FILTER_TAPS, SUPERRES_SCALE_BITS, SUPERRES_SCALE_MASK,
|
||||
UPSCALE_FILTER,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
use crate::state::FrameState;
|
||||
|
||||
/// Upscale a frame horizontally from FrameWidth to UpscaledWidth (7.16).
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
use crate::decoder::RefFrame;
|
||||
use crate::frame::{FrameBuf, MiInfo};
|
||||
use crate::header::{FrameHeader, RefInfo, SequenceHeader};
|
||||
use crate::spec_tables::{DEFAULT_INTRA_FRAME_Y_MODE_CDF, NUM_REF_FRAMES, RESTORE_NONE, SUBSAMPLED_SIZE};
|
||||
use crate::spec_tables::*;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
+1
-21
@@ -6,27 +6,7 @@ use crate::cdf::Cdfs;
|
||||
use crate::frame::{MiInfo, Mv};
|
||||
use crate::header::{FrameHeader, NONE, SequenceHeader};
|
||||
use crate::intra::{IntraParams, is_directional_mode, predict_intra, round2_signed};
|
||||
use crate::spec_tables::{
|
||||
AC_QLOOKUP, ADJUSTED_TX_SIZE, ADST_FLIPADST, BLOCK_4X4, BLOCK_8X8, BLOCK_64X64, BLOCK_128X128, BR_CDF_SIZE, CFL_SIGN_NEG, CFL_SIGN_ZERO,
|
||||
COEFF_BASE_CTX_OFFSET, COEFF_BASE_POS_CTX_OFFSET, COEFF_BASE_RANGE, COMPOUND_AVERAGE, DC_PRED, DC_QLOOKUP, DCT_DCT, DCT_FLIPADST,
|
||||
DEFAULT_SCAN_4X4, DEFAULT_SCAN_4X8, DEFAULT_SCAN_4X16, DEFAULT_SCAN_8X4, DEFAULT_SCAN_8X8, DEFAULT_SCAN_8X16, DEFAULT_SCAN_8X32,
|
||||
DEFAULT_SCAN_16X4, DEFAULT_SCAN_16X8, DEFAULT_SCAN_16X16, DEFAULT_SCAN_16X32, DEFAULT_SCAN_32X8, DEFAULT_SCAN_32X16, DEFAULT_SCAN_32X32,
|
||||
DELTA_LF_SMALL, DELTA_Q_SMALL, FILTER_INTRA_MODE_TO_INTRA_DIR, FLIPADST_ADST, FLIPADST_DCT, FLIPADST_FLIPADST, FRAME_LF_COUNT, H_ADST, H_DCT,
|
||||
H_FLIPADST, H_PRED, IDTX, II_DC_PRED, II_H_PRED, II_V_PRED, INTRA_FRAME, INTRA_MODE_CONTEXT, MAG_REF_OFFSET_WITH_TX_CLASS, MAX_ANGLE_DELTA,
|
||||
MAX_LOOP_FILTER, MAX_TX_DEPTH_TABLE, MAX_TX_SIZE_RECT, MAX_VARTX_DEPTH, MCOL_SCAN_4X4, MCOL_SCAN_4X8, MCOL_SCAN_4X16, MCOL_SCAN_8X4,
|
||||
MCOL_SCAN_8X8, MCOL_SCAN_8X16, MCOL_SCAN_16X4, MCOL_SCAN_16X8, MCOL_SCAN_16X16, MI_HEIGHT_LOG2, MI_WIDTH_LOG2, MODE_TO_TXFM, MROW_SCAN_4X4,
|
||||
MROW_SCAN_4X8, MROW_SCAN_4X16, MROW_SCAN_8X4, MROW_SCAN_8X8, MROW_SCAN_8X16, MROW_SCAN_16X4, MROW_SCAN_16X8, MROW_SCAN_16X16,
|
||||
NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, NUM_BASE_LEVELS, PALETTE_COLOR_CONTEXT, PALETTE_COLOR_HASH_MULTIPLIERS, PALETTE_COLORS,
|
||||
PALETTE_NUM_NEIGHBORS, PARTITION_HORZ, PARTITION_HORZ_4, PARTITION_HORZ_A, PARTITION_HORZ_B, PARTITION_NONE, PARTITION_SPLIT, PARTITION_SUBSIZE,
|
||||
PARTITION_VERT, PARTITION_VERT_4, PARTITION_VERT_A, PARTITION_VERT_B, QM_OFFSET, QUANTIZER_MATRIX, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_WIENER,
|
||||
SEG_LVL_SKIP, SGR_PARAMS, SGRPROJ_PARAMS_BITS, SGRPROJ_PRJ_BITS, SGRPROJ_PRJ_SUBEXP_K, SIG_REF_DIFF_OFFSET, SIG_REF_DIFF_OFFSET_NUM, SIMPLE,
|
||||
SMOOTH_H_PRED, SMOOTH_PRED, SMOOTH_V_PRED, SPLIT_TX_SIZE, SUPERRES_NUM, TX_4X4, TX_4X8, TX_4X16, TX_8X4, TX_8X8, TX_8X16, TX_8X32, TX_16X4,
|
||||
TX_16X8, TX_16X16, TX_16X32, TX_16X64, TX_32X8, TX_32X16, TX_32X32, TX_32X64, TX_64X16, TX_64X32, TX_64X64, TX_CLASS_2D, TX_CLASS_HORIZ,
|
||||
TX_CLASS_VERT, TX_HEIGHT, TX_HEIGHT_LOG2, TX_MODE_SELECT, TX_SET_DCTONLY, TX_SET_INTER_1, TX_SET_INTER_2, TX_SET_INTER_3, TX_SET_INTRA_1,
|
||||
TX_SET_INTRA_2, TX_SIZE_SQR, TX_SIZE_SQR_UP, TX_SIZES, TX_SIZES_ALL, TX_TYPE_IN_SET_INTER, TX_TYPE_IN_SET_INTRA, TX_TYPE_INTER_INV_SET1,
|
||||
TX_TYPE_INTER_INV_SET2, TX_TYPE_INTER_INV_SET3, TX_TYPE_INTRA_INV_SET1, TX_TYPE_INTRA_INV_SET2, TX_WIDTH, TX_WIDTH_LOG2, UV_CFL_PRED, V_ADST,
|
||||
V_DCT, V_FLIPADST, V_PRED,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
use crate::state::{FrameShared, TileState};
|
||||
use crate::symbol::SymbolDecoder;
|
||||
use crate::transform::inverse_transform_2d;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
//! Inverse transforms (spec 7.13), implemented step by step as specified.
|
||||
|
||||
use crate::spec_tables::{
|
||||
ADST_ADST, ADST_DCT, ADST_FLIPADST, COS128_LOOKUP, DCT_ADST, DCT_DCT, DCT_FLIPADST, FLIPADST_ADST, FLIPADST_DCT, FLIPADST_FLIPADST, H_ADST,
|
||||
H_DCT, H_FLIPADST, TRANSFORM_ROW_SHIFT, TX_HEIGHT_LOG2, TX_WIDTH_LOG2, V_ADST, V_DCT, V_FLIPADST,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
|
||||
#[inline(always)]
|
||||
fn round2_64(x: i64, n: u32) -> i32 {
|
||||
|
||||
@@ -171,22 +171,6 @@ 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| {
|
||||
@@ -195,9 +179,6 @@ 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));
|
||||
@@ -341,9 +322,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 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 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 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 a .pptx or .potx's slide master and layouts (slides move to the matching layouts), or a .thmx's theme",
|
||||
"{path} | {name, data: base64} — apply the theme of a .thmx, .pptx or .potx file",
|
||||
has_doc,
|
||||
browse_theme
|
||||
),
|
||||
@@ -161,13 +161,6 @@ 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| {
|
||||
@@ -187,58 +180,6 @@ 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) {
|
||||
@@ -913,14 +854,6 @@ 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;
|
||||
}
|
||||
@@ -936,7 +869,6 @@ 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 {
|
||||
@@ -981,11 +913,7 @@ 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: date_now(&hf.date_format),
|
||||
props: Default::default(),
|
||||
kind: RunKind::Field { field: date_kind(&hf.date_format).into() },
|
||||
}],
|
||||
runs: vec![Run { text: today(), props: Default::default(), kind: RunKind::Field { field: "datetime1".into() } }],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
@@ -1006,9 +934,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 || format_given) => {
|
||||
(Some(pos), true) if k == PhType::Date && date_given => {
|
||||
if let Some(sh) = sl.shapes.get_mut(pos) {
|
||||
set_date(sh, &hf.date_text, &hf.date_format);
|
||||
set_date(sh, &hf.date_text);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -1020,33 +948,15 @@ 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, format: &str) {
|
||||
fn set_date(sh: &mut Shape, fixed: &str) {
|
||||
let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs);
|
||||
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 has_field = runs().any(|r| matches!(&r.kind, RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field)));
|
||||
let props = runs().next().map(|r| r.props.clone()).unwrap_or_default();
|
||||
let want = date_kind(format);
|
||||
let (text, kind) = match fixed {
|
||||
// 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 => return,
|
||||
"" => (today(), RunKind::Field { field: "datetime1".into() }),
|
||||
_ if !has_field && sh.text.as_ref().is_some_and(|t| t.text() == fixed) => return,
|
||||
_ => (fixed.to_string(), RunKind::Text),
|
||||
};
|
||||
@@ -2045,10 +1955,8 @@ mod tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod browse_theme_tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::PhType;
|
||||
use deckcraft_model::text::RunKind;
|
||||
use deckcraft_model::{Background, Fill, PhType};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Session, Target};
|
||||
@@ -2074,108 +1982,6 @@ 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()
|
||||
}
|
||||
@@ -2326,90 +2132,3 @@ 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|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!(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!(
|
||||
query "file.info",
|
||||
"Info",
|
||||
@@ -167,10 +167,8 @@ 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();
|
||||
let rtf = deckcraft_format::rtf::sniff_rtf(bytes);
|
||||
if rtf || lower.ends_with(".txt") || lower.ends_with(".md") {
|
||||
if 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);
|
||||
@@ -221,12 +219,6 @@ 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);
|
||||
@@ -342,7 +334,6 @@ 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())),
|
||||
@@ -418,7 +409,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", "rtf"];
|
||||
pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "gif", "txt", "md"];
|
||||
|
||||
/// 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
|
||||
@@ -451,8 +442,6 @@ 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"
|
||||
}
|
||||
@@ -633,7 +622,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"secondsPerSlide": f64::from(out.delay_cs) / 100.0,
|
||||
}))
|
||||
}
|
||||
"pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" | "rtf" => {
|
||||
"pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" => {
|
||||
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, ShapeKind};
|
||||
use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, Shape};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::text::{format_body, format_para, format_run};
|
||||
@@ -340,27 +340,6 @@ 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.
|
||||
@@ -713,8 +692,7 @@ 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| {
|
||||
let last = body.paragraphs.len().saturating_sub(1);
|
||||
for pi in a.0..=b.0.min(last) {
|
||||
for pi in a.0..=b.0 {
|
||||
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,7 +4,6 @@
|
||||
//! 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};
|
||||
|
||||
@@ -58,44 +57,9 @@ fn roles(shapes: &[Shape]) -> Roles {
|
||||
r
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// The ideas for slide `index` (none when it has nothing to arrange).
|
||||
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()
|
||||
@@ -285,7 +249,6 @@ 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()
|
||||
@@ -451,91 +414,6 @@ 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, 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?}",
|
||||
"{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?}",
|
||||
has_selection,
|
||||
chart_options
|
||||
),
|
||||
@@ -577,7 +577,7 @@ fn commit_album(s: &mut Session, items: Vec<AlbumItem>, opts: AlbumOpts, replace
|
||||
}
|
||||
|
||||
fn album_norm(s: &str) -> String {
|
||||
s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase()
|
||||
s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn album_layout(p: &Value) -> Result<AlbumLayout> {
|
||||
@@ -770,7 +770,7 @@ fn image_px(bytes: &[u8]) -> (f64, f64) {
|
||||
}
|
||||
|
||||
fn file_stem(name: &str) -> String {
|
||||
let base = std::path::Path::new(name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(name);
|
||||
let base = std::path::Path::new(name).file_name().and_then(|n| n.to_str()).unwrap_or(name);
|
||||
let stem = match base.rsplit_once('.') {
|
||||
Some((s, ext)) if !s.is_empty() && !ext.is_empty() => s,
|
||||
_ => base,
|
||||
@@ -1804,19 +1804,7 @@ 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() {
|
||||
@@ -1830,10 +1818,6 @@ 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;
|
||||
|
||||
+12
-180
@@ -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]], segments: [[straight|curved|null]]} vertices of a freeform and the segment ending at each", has_selection, 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!("shape.movePoint", "Move Point", [], None, "{path?: 0, index, x, y: slide pt, id?}", has_selection, move_point),
|
||||
cmd!(
|
||||
"shape.addPoint",
|
||||
@@ -54,33 +54,6 @@ 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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -308,76 +281,6 @@ 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);
|
||||
@@ -499,7 +402,6 @@ 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}));
|
||||
@@ -534,16 +436,17 @@ 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 (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}))
|
||||
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}))
|
||||
}
|
||||
|
||||
/// Runs `f` on the segments of path `path` of `id` (in shape-local points), then refits the box.
|
||||
@@ -881,74 +784,3 @@ 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,41 +10,22 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
|
||||
use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use deckcraft_model::{LayoutId, 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!(
|
||||
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}))
|
||||
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
|
||||
)]
|
||||
}
|
||||
|
||||
/// Point every media id under `x` at its copy in the destination (`map`: source → destination).
|
||||
@@ -133,9 +114,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(cmd: &str, doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
|
||||
fn renumber(doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
|
||||
let old = shape_ids(shapes);
|
||||
super::edit::reid(cmd, doc, shapes)?;
|
||||
super::edit::reid("slide.reuse", doc, shapes)?;
|
||||
let map: HashMap<ShapeId, ShapeId> = old.into_iter().zip(shape_ids(shapes)).collect();
|
||||
remap_connectors(shapes, &map, 0);
|
||||
Ok(map)
|
||||
@@ -190,7 +171,19 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
masters_done.push(m.id);
|
||||
let m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
|
||||
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)?;
|
||||
}
|
||||
doc.masters.push(Arc::new(m));
|
||||
}
|
||||
}
|
||||
@@ -199,11 +192,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("slide.reuse", doc, &src, &media_refs::slide_media(from), &mut media_map)?;
|
||||
copy_media(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("slide.reuse", doc, &mut sl.shapes)?;
|
||||
let map = renumber(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));
|
||||
@@ -234,35 +227,9 @@ 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(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: &mut HashMap<MediaId, MediaId>) -> Result<()> {
|
||||
fn copy_media(doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: &mut HashMap<MediaId, MediaId>) -> Result<()> {
|
||||
for &id in used {
|
||||
if map.contains_key(&id) {
|
||||
continue;
|
||||
@@ -270,12 +237,12 @@ fn copy_media(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[Med
|
||||
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(cmd, NO_ID_SPACE))?);
|
||||
item.id = MediaId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", 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(cmd, NO_ID_SPACE))?
|
||||
doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?
|
||||
};
|
||||
map.insert(id, new);
|
||||
}
|
||||
@@ -430,34 +397,4 @@ 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,15 +59,6 @@ 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, _| {
|
||||
@@ -664,7 +655,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::pending(&st.doc, c), "byPosition": c.by_position}))
|
||||
Ok(json!({"name": name, "changes": crate::compare::diff(&st.doc, &c.revised, c.by_position), "byPosition": c.by_position}))
|
||||
}
|
||||
|
||||
fn compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -672,7 +663,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, rejected: Default::default() });
|
||||
st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position });
|
||||
s.ui_requests.push(UiRequest::Pane { id: "revisions".into() });
|
||||
revisions_json(s.doc()?)
|
||||
}
|
||||
@@ -682,8 +673,7 @@ fn revisions(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn accept(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmp = s.doc()?.compare.clone().ok_or_else(|| bad("review.accept", "not comparing"))?;
|
||||
let rev = cmp.revised.clone();
|
||||
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 all = bool_param(p, "all").unwrap_or(false);
|
||||
let index = usize_param(p, "index");
|
||||
if !all && index.is_none() {
|
||||
@@ -694,7 +684,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::pending(doc, &cmp);
|
||||
let changes = crate::compare::diff(doc, &rev, by_pos);
|
||||
let c = if all {
|
||||
changes.into_iter().find(|c| !(c.kind == crate::compare::ChangeKind::Deleted && doc.slides.len() <= 1))
|
||||
} else {
|
||||
@@ -717,24 +707,6 @@ 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()
|
||||
|
||||
@@ -107,16 +107,14 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
}
|
||||
if closing {
|
||||
let kind = tag_kind(&name);
|
||||
if !kind.is_empty() {
|
||||
let search_start = stack.len().saturating_sub(64);
|
||||
if let Some(offset) = stack[search_start..].iter().rposition(|e| e.0 == kind) {
|
||||
let pos = search_start + offset;
|
||||
let (_, db, di, du) = stack[pos];
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
underline -= du;
|
||||
stack.truncate(pos);
|
||||
}
|
||||
if !kind.is_empty()
|
||||
&& let Some(pos) = stack.iter().rposition(|e| e.0 == kind)
|
||||
{
|
||||
let (_, db, di, du) = stack[pos];
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
underline -= du;
|
||||
stack.truncate(pos);
|
||||
}
|
||||
if is_block(&name) {
|
||||
new_para(&mut paragraphs);
|
||||
@@ -132,7 +130,7 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
bold += db;
|
||||
italic += di;
|
||||
underline += du;
|
||||
if !token.ends_with('/') && stack.len() < 128 {
|
||||
if !token.ends_with('/') {
|
||||
stack.push((tag_kind(&name), db, di, du));
|
||||
} else {
|
||||
bold -= db;
|
||||
@@ -304,11 +302,9 @@ fn decode_text(raw: &str) -> String {
|
||||
while let Some(at) = rest.find('&') {
|
||||
out.push_str(&collapse_space(&rest[..at]));
|
||||
rest = &rest[at..];
|
||||
let search_window = rest.len().min(32);
|
||||
let Some(end) = rest[..search_window].find(';') else {
|
||||
out.push('&');
|
||||
rest = &rest[1..];
|
||||
continue;
|
||||
let Some(end) = rest.find(';') else {
|
||||
out.push_str(&collapse_space(rest));
|
||||
break;
|
||||
};
|
||||
let entity = &rest[1..end];
|
||||
let decoded = match entity {
|
||||
@@ -337,12 +333,7 @@ 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'));
|
||||
// `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()?;
|
||||
let n = if let Some(hex) = hex { u32::from_str_radix(hex, 16).ok()? } else { digits.parse().ok()? };
|
||||
char::from_u32(n).filter(|c| !c.is_control() || *c == '\n')
|
||||
}
|
||||
|
||||
@@ -361,13 +352,6 @@ 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>";
|
||||
@@ -407,10 +391,6 @@ mod tests {
|
||||
assert_eq!(text("<p>R&D costs; up</p>"), "R&D costs; up");
|
||||
assert_eq!(text("<p>AT&T; Q&A</p>"), "AT&T; Q&A");
|
||||
assert_eq!(text("<p>A & B & C & D</p>"), "A & B & C & D");
|
||||
|
||||
// Verify many bare ampersands do not cause quadratic scan freeze
|
||||
let hostile = format!("<p>{}</p>", "&".repeat(2000));
|
||||
assert_eq!(text(&hostile), "&".repeat(2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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 opt_usize_param(p, "index", "slide.hide")? {
|
||||
let targets: Vec<usize> = match usize_param(p, "index") {
|
||||
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 opt_usize_param(p, "index", "slide.layout")? {
|
||||
let targets: Vec<usize> = match usize_param(p, "index") {
|
||||
Some(i) => vec![i],
|
||||
None => {
|
||||
let st = s.doc()?;
|
||||
|
||||
@@ -417,8 +417,7 @@ 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;
|
||||
let last = body.paragraphs.len().saturating_sub(1);
|
||||
for pi in at.0..=c.0.min(last) {
|
||||
for pi in at.0..=c.0 {
|
||||
if let Some(para) = body.paragraphs.get_mut(pi) {
|
||||
promote_rtl_if_needed(para, &inherited);
|
||||
}
|
||||
@@ -785,8 +784,7 @@ 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;
|
||||
let last = body.paragraphs.len().saturating_sub(1);
|
||||
for pi in at.0..=c.0.min(last) {
|
||||
for pi in at.0..=c.0 {
|
||||
if let Some(para) = body.paragraphs.get_mut(pi) {
|
||||
promote_rtl_if_needed(para, &inherited);
|
||||
}
|
||||
@@ -1126,7 +1124,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.saturating_add(1).min(body.paragraphs.len().saturating_sub(1)), caret.1),
|
||||
"down" => ((caret.0 + 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)),
|
||||
},
|
||||
@@ -1191,11 +1189,9 @@ 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.saturating_add(1) < b.paragraphs.len()) { (at.0.saturating_add(1), 0) } else { (at.0, n) };
|
||||
let end = if body.is_some_and(|b| at.0 + 1 < b.paragraphs.len()) { (at.0 + 1, 0) } else { (at.0, n) };
|
||||
s.select(|_, sel| {
|
||||
if let Some(t) = sel.text.as_mut() {
|
||||
t.anchor = (at.0, 0);
|
||||
@@ -1375,8 +1371,7 @@ 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());
|
||||
}
|
||||
let last = body.paragraphs.len().saturating_sub(1);
|
||||
for pi in a.0..=b.0.min(last) {
|
||||
for pi in a.0..=b.0 {
|
||||
if let Some(p) = body.paragraphs.get_mut(pi) {
|
||||
f(p);
|
||||
}
|
||||
@@ -1524,23 +1519,3 @@ 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 or rejecting it.
|
||||
//! by slide, and accepting a revised slide into the open one.
|
||||
//!
|
||||
//! 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,54 +18,6 @@ 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).
|
||||
@@ -74,7 +26,7 @@ pub fn unrelated(cur: &Presentation, rev: &Presentation) -> bool {
|
||||
!cur.slides.iter().any(|s| ids.contains(&s.id))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ChangeKind {
|
||||
/// Only in the revised deck.
|
||||
@@ -482,50 +434,6 @@ 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();
|
||||
@@ -544,65 +452,4 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
+5
-59
@@ -8,14 +8,6 @@
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
|
||||
crate::file_access::check_read(path)?;
|
||||
let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
if !meta.is_file() {
|
||||
return Err(format!("{path}: not a regular file"));
|
||||
}
|
||||
const MAX_FILE_SIZE: u64 = 2 << 30; // 2 GiB
|
||||
if meta.len() > MAX_FILE_SIZE {
|
||||
return Err(format!("{path}: file size {} exceeds 2 GiB limit", meta.len()));
|
||||
}
|
||||
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
@@ -36,29 +28,21 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
crate::file_access::check_write(path)?;
|
||||
let err = |e: std::io::Error| format!("{path}: {e}");
|
||||
|
||||
let target_path = match std::fs::canonicalize(path) {
|
||||
Ok(canonical) => canonical,
|
||||
Err(_) => std::path::PathBuf::from(path),
|
||||
};
|
||||
let target_str = target_path.to_str().unwrap_or(path);
|
||||
let existing_perms = std::fs::metadata(&target_path).ok().map(|m| m.permissions());
|
||||
|
||||
// A uniquely named temporary sibling, created exclusively (never following or reusing an
|
||||
// existing file or symlink), is renamed over the destination: concurrent saves never share
|
||||
// temporary bytes.
|
||||
let mut attempts = 0;
|
||||
let (tmp, mut file) = loop {
|
||||
let tmp = format!("{target_str}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
|
||||
let tmp = format!("{path}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
|
||||
Ok(f) => break (tmp, f),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1,
|
||||
Err(e) => return Err(err(e)),
|
||||
}
|
||||
};
|
||||
if let Some(perms) = existing_perms {
|
||||
let _ = file.set_permissions(perms);
|
||||
}
|
||||
let written = file.write_all(bytes).and_then(|()| file.sync_all());
|
||||
drop(file);
|
||||
let result = written.and_then(|()| std::fs::rename(&tmp, &target_path));
|
||||
let result = written.and_then(|()| std::fs::rename(&tmp, path));
|
||||
if let Err(e) = result {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(err(e));
|
||||
@@ -83,41 +67,3 @@ pub fn file_len(path: &str) -> Option<u64> {
|
||||
pub fn file_len(_path: &str) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn read_file_rejects_directory() {
|
||||
let dir = std::env::temp_dir();
|
||||
let res = read_file(dir.to_str().unwrap());
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_file_preserves_permissions_and_writes_content() {
|
||||
let tmp_dir = std::env::temp_dir().join("deckcraft_io_test");
|
||||
let _ = std::fs::create_dir_all(&tmp_dir);
|
||||
let test_file = tmp_dir.join("test_write.txt");
|
||||
let path_str = test_file.to_str().unwrap();
|
||||
|
||||
assert!(write_file(path_str, b"hello world").is_ok());
|
||||
let read = read_file(path_str).unwrap();
|
||||
assert_eq!(read, b"hello world");
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o640);
|
||||
std::fs::set_permissions(&test_file, perms).unwrap();
|
||||
|
||||
assert!(write_file(path_str, b"updated world").is_ok());
|
||||
let new_meta = std::fs::metadata(&test_file).unwrap();
|
||||
assert_eq!(new_meta.permissions().mode() & 0o777, 0o640);
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&test_file);
|
||||
let _ = std::fs::remove_dir(&tmp_dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,6 @@ 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) {
|
||||
|
||||
@@ -85,20 +85,8 @@ fn owner_alive(dir: &Path, owner: u64) -> bool {
|
||||
|
||||
/// Write `bytes` to `path` through a temporary file and a rename, so a crash never leaves it torn.
|
||||
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
use std::io::Write;
|
||||
let tmp = path.with_extension(format!("{}.tmp", path.extension().and_then(|e| e.to_str()).unwrap_or("")));
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
let mut file = opts.open(&tmp).map_err(|e| EngineError::Other(format!("{}: {e}", tmp.display())))?;
|
||||
file.write_all(bytes).and_then(|()| file.sync_all()).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
EngineError::Other(format!("{}: {e}", path.display()))
|
||||
})
|
||||
std::fs::write(&tmp, bytes).and_then(|_| std::fs::rename(&tmp, path)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))
|
||||
}
|
||||
|
||||
/// Presentations reopened per start; the rest stay on disk for the next one, so a folder
|
||||
|
||||
@@ -322,36 +322,6 @@ 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();
|
||||
@@ -368,19 +338,6 @@ 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();
|
||||
@@ -1580,24 +1537,6 @@ 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]
|
||||
@@ -2189,126 +2128,6 @@ 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]
|
||||
@@ -3279,70 +3098,6 @@ 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);
|
||||
@@ -4138,32 +3893,6 @@ 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();
|
||||
@@ -4674,53 +4403,3 @@ 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,9 +266,6 @@ 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.
|
||||
@@ -697,12 +694,7 @@ 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(_)));
|
||||
// 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 enter_text = (!toggle && ((text && (selected || is_text_first(st, id))) || (table && selected))).then_some((id, p));
|
||||
let ids2 = ids.clone();
|
||||
self.select(|_, sel| {
|
||||
sel.shapes = ids2;
|
||||
@@ -1251,12 +1243,6 @@ 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;
|
||||
@@ -1264,13 +1250,6 @@ 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(),
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
//! #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,7 +6,6 @@
|
||||
|
||||
mod chart_parts;
|
||||
mod finite;
|
||||
pub mod rtf;
|
||||
|
||||
#[cfg(test)]
|
||||
mod chart_parts_tests;
|
||||
@@ -662,7 +661,6 @@ 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');
|
||||
}
|
||||
@@ -1102,33 +1100,3 @@ 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:?}");
|
||||
}
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use crate::cabac::Cabac;
|
||||
use crate::error::{Result, ensure, invalid};
|
||||
use crate::mbtypes::{MbTypeInfo, Part, SubMbInfo, b_mb_type, b_sub_mb_type, intra_mb_type, p_mb_type, p_sub_mb_type};
|
||||
use crate::mbtypes::*;
|
||||
use crate::picture::MbKind;
|
||||
use crate::slice::SliceType;
|
||||
use crate::slicedec::{MbCur, SliceDecoder};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! CAVLC residual block parsing (9.2).
|
||||
|
||||
use crate::cavlc_tables::{COEFF_TOKEN, RUN_BEFORE, TOTAL_ZEROS_2X2, TOTAL_ZEROS_2X4, TOTAL_ZEROS_4X4};
|
||||
use crate::cavlc_tables::*;
|
||||
use crate::error::{Result, ensure};
|
||||
use deckcraft_bitstream::BitReader;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -232,7 +232,7 @@ fn filter_vertical<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y: u
|
||||
let mut w = [0u64; N];
|
||||
for (i, w) in w.iter_mut().enumerate() {
|
||||
let o = (y + i) * stride + x - 4;
|
||||
let Some(&b) = pix.get(o..).and_then(<[_]>::first_chunk::<8>) else {
|
||||
let Some(&b) = pix.get(o..).and_then(|p| p.first_chunk::<8>()) else {
|
||||
return;
|
||||
};
|
||||
*w = u64::from_le_bytes(b);
|
||||
@@ -259,7 +259,7 @@ fn filter_horizontal<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y:
|
||||
let base = (y - 4) * stride + x;
|
||||
let mut s = [[0i16; N]; 8];
|
||||
for (k, row) in s.iter_mut().enumerate() {
|
||||
let Some(src) = pix.get(base + k * stride..).and_then(<[_]>::first_chunk::<N>) else {
|
||||
let Some(src) = pix.get(base + k * stride..).and_then(|p| p.first_chunk::<N>()) else {
|
||||
return;
|
||||
};
|
||||
for i in 0..N {
|
||||
@@ -269,7 +269,7 @@ fn filter_horizontal<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y:
|
||||
filter_lanes(&mut s, ep, chroma);
|
||||
let (k0, k1) = if chroma { (3, 5) } else { (1, 7) };
|
||||
for (k, row) in s.iter().enumerate().take(k1).skip(k0) {
|
||||
let Some(dst) = pix.get_mut(base + k * stride..).and_then(<[_]>::first_chunk_mut::<N>) else {
|
||||
let Some(dst) = pix.get_mut(base + k * stride..).and_then(|p| p.first_chunk_mut::<N>()) else {
|
||||
return;
|
||||
};
|
||||
for i in 0..N {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Sequence and picture parameter sets (7.3.2.1, 7.3.2.2) including VUI and scaling lists.
|
||||
|
||||
use crate::error::{Result, ensure, unsupported};
|
||||
use crate::tables::{DEFAULT_4X4_INTER, DEFAULT_4X4_INTRA, DEFAULT_8X8_INTER, DEFAULT_8X8_INTRA, ZIGZAG4, ZIGZAG8};
|
||||
use crate::tables::*;
|
||||
use deckcraft_bitstream::BitReader;
|
||||
|
||||
/// Scaling matrices after applying fall-back rules, stored in raster order (weightScale).
|
||||
|
||||
@@ -301,7 +301,7 @@ fn plane_allocator() -> fn(usize) -> Vec<u8> {
|
||||
fn copy_line<const N: usize>(line: &[u8], x0: i32, out: &mut [u8], w: usize) {
|
||||
if w <= N
|
||||
&& x0 >= 0
|
||||
&& let Some(src) = line.get(x0 as usize..).and_then(<[_]>::first_chunk::<N>)
|
||||
&& let Some(src) = line.get(x0 as usize..).and_then(|l| l.first_chunk::<N>())
|
||||
&& let Some(dst) = out.first_chunk_mut::<N>()
|
||||
{
|
||||
*dst = *src;
|
||||
|
||||
@@ -5,11 +5,11 @@ use crate::deblock;
|
||||
use crate::error::{Result, ensure, invalid};
|
||||
use crate::inter::{self, Weight};
|
||||
use crate::intra::{self, Avail};
|
||||
use crate::mbtypes::{CBP_ME, MbTypeInfo, Part, SubMbInfo, SubPart, b_mb_type, b_sub_mb_type, intra_mb_type, p_mb_type, p_sub_mb_type};
|
||||
use crate::mbtypes::*;
|
||||
use crate::params::{Pps, Sps};
|
||||
use crate::picture::{Frame, FrameRef, MbKind, MbState, Planes, RefPic};
|
||||
use crate::picture::*;
|
||||
use crate::slice::{SliceHeader, SliceType};
|
||||
use crate::tables::{BLK4_XY, QPC_TABLE, RASTER_TO_BLK4, ZIGZAG4, ZIGZAG8};
|
||||
use crate::tables::*;
|
||||
use crate::transform::{self, LevelScale};
|
||||
use deckcraft_bitstream::BitReader;
|
||||
|
||||
@@ -907,7 +907,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
let stride = self.pic.planes.width;
|
||||
let px = self.mb_x * 16 + (raster & 3) * 4;
|
||||
let py = self.mb_y * 16 + (raster >> 2) * 4;
|
||||
let Some(blk) = self.s.coef.get_mut(raster * 16..).and_then(<[_]>::first_chunk_mut::<16>) else {
|
||||
let Some(blk) = self.s.coef.get_mut(raster * 16..).and_then(|c| c.first_chunk_mut::<16>()) else {
|
||||
return;
|
||||
};
|
||||
transform::idct4_add(blk, &mut self.pic.planes.y[py * stride + px..], stride);
|
||||
@@ -921,7 +921,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
let stride = self.pic.planes.width;
|
||||
let px = self.mb_x * 16 + (b8 & 1) * 8;
|
||||
let py = self.mb_y * 16 + (b8 >> 1) * 8;
|
||||
let Some(blk) = self.s.coef.get_mut(b8 * 64..).and_then(<[_]>::first_chunk_mut::<64>) else {
|
||||
let Some(blk) = self.s.coef.get_mut(b8 * 64..).and_then(|c| c.first_chunk_mut::<64>()) else {
|
||||
return;
|
||||
};
|
||||
transform::idct8_add(blk, &mut self.pic.planes.y[py * stride + px..], stride);
|
||||
@@ -940,7 +940,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
}
|
||||
let px = self.mb_x * 8 + (b & 1) * 4;
|
||||
let py = self.mb_y * 8 + (b >> 1) * 4;
|
||||
let Some(blk) = self.s.coef_c[c].get_mut(b * 16..).and_then(<[_]>::first_chunk_mut::<16>) else {
|
||||
let Some(blk) = self.s.coef_c[c].get_mut(b * 16..).and_then(|c| c.first_chunk_mut::<16>()) else {
|
||||
continue;
|
||||
};
|
||||
let plane = if c == 0 { &mut self.pic.planes.cb } else { &mut self.pic.planes.cr };
|
||||
|
||||
@@ -107,7 +107,7 @@ struct Pic {
|
||||
|
||||
impl Pic {
|
||||
fn is_p(&self) -> bool {
|
||||
!self.idr && !self.b && self.mbs.iter().any(Option::is_none)
|
||||
!self.idr && !self.b && self.mbs.iter().any(|m| m.is_none())
|
||||
}
|
||||
fn inter(&self) -> bool {
|
||||
self.b || self.is_p()
|
||||
|
||||
@@ -117,7 +117,7 @@ pub fn idct4_add(d: &[i32; 16], dst: &mut [u8], stride: usize) {
|
||||
o[3][j] = e - h;
|
||||
}
|
||||
for (i, row) in o.iter().enumerate() {
|
||||
let Some(line) = dst.get_mut(i * stride..).and_then(<[_]>::first_chunk_mut::<4>) else {
|
||||
let Some(line) = dst.get_mut(i * stride..).and_then(|d| d.first_chunk_mut::<4>()) else {
|
||||
return;
|
||||
};
|
||||
for (p, &v) in line.iter_mut().zip(row) {
|
||||
@@ -173,7 +173,7 @@ pub fn idct8_add(d: &[i32; 64], dst: &mut [u8], stride: usize) {
|
||||
}
|
||||
}
|
||||
for (i, row) in o.iter().enumerate() {
|
||||
let Some(line) = dst.get_mut(i * stride..).and_then(<[_]>::first_chunk_mut::<8>) else {
|
||||
let Some(line) = dst.get_mut(i * stride..).and_then(|d| d.first_chunk_mut::<8>()) else {
|
||||
return;
|
||||
};
|
||||
for (p, &v) in line.iter_mut().zip(row) {
|
||||
|
||||
@@ -25,7 +25,7 @@ fn filt_w<const T: usize, const W: usize>(src: &[i16], step: usize, f: &[i8; T],
|
||||
let mut acc = [0i32; W];
|
||||
for k in 0..T {
|
||||
let c = f[k] as i32;
|
||||
let Some(p) = src.get(k * step..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
let Some(p) = src.get(k * step..).and_then(|s| s.first_chunk::<W>()) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
|
||||
@@ -10,11 +10,7 @@ use crate::intra::{self, Refs};
|
||||
use crate::params::{Layout, Pps, Sps};
|
||||
use crate::picture::{FrameRef, RefPic};
|
||||
use crate::slice::{SliceHeader, SliceType};
|
||||
use crate::spec_tables::{
|
||||
ABS_MVD_GT0, ABS_MVD_GT1, CBF_CHROMA, CBF_LUMA, CODED_SUB_BLOCK, CU_QP_DELTA_ABS, CU_SKIP, CU_TRANSQUANT_BYPASS, GT1, GT2, INTER_PRED_IDC,
|
||||
INTRA_CHROMA, LAST_X_PREFIX, LAST_Y_PREFIX, MERGE_FLAG, MERGE_IDX, MVP_FLAG, PART_MODE, PRED_MODE, PREV_INTRA_LUMA, REF_IDX, RQT_ROOT_CBF,
|
||||
SAO_MERGE, SAO_TYPE, SIG_COEFF, SPLIT_CU, SPLIT_TRANSFORM, TRANSFORM_SKIP,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
use crate::tables::{CTX_IDX_MAP, LEVEL_SCALE, SCAN_4, qpc_420, scan_order};
|
||||
use crate::transform;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use crate::Decoder;
|
||||
use crate::cabac::{Contexts, NEXT_STATE, RANGE_TAB_LPS, init_contexts};
|
||||
use crate::spec_tables::{CU_SKIP, NUM_CTX, PRED_MODE, SPLIT_CU};
|
||||
use crate::spec_tables::*;
|
||||
use deckcraft_bitstream::{BitWriter, escape_rbsp};
|
||||
|
||||
const W: usize = 96;
|
||||
|
||||
@@ -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", "rtf", "pdf", "gif"], "description": "Output format (default: from the path's extension)"},
|
||||
"format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "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)"),
|
||||
|
||||
Vendored
+4
-6
@@ -28,13 +28,11 @@ for f in range(6):
|
||||
sys.stdout.buffer.write(bytes(v for y in range(h) for x in range(w) for v in ((x * 4 + f * 20) % 256, (y * 5) % 256, (f * 40) % 256)))
|
||||
" > "$out"
|
||||
}
|
||||
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/deckcraft-fixture.XXXXXX")"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
frames 64 48 "$TMP_DIR/deckcraft-fixture-64x48.rgb"
|
||||
frames 64 64 "$TMP_DIR/deckcraft-fixture-64x64.rgb"
|
||||
VID="-f rawvideo -pix_fmt rgb24 -s 64x48 -r 10 -i $TMP_DIR/deckcraft-fixture-64x48.rgb"
|
||||
frames 64 48 /tmp/deckcraft-fixture-64x48.rgb
|
||||
frames 64 64 /tmp/deckcraft-fixture-64x64.rgb
|
||||
VID="-f rawvideo -pix_fmt rgb24 -s 64x48 -r 10 -i /tmp/deckcraft-fixture-64x48.rgb"
|
||||
$FF $VID $TONE44 -c:v libx264 -pix_fmt yuv420p -g 5 -bf 2 -c:a aac -b:a 48k -shortest $META clip-h264.mp4
|
||||
$FF $VID -c:v libx264 -pix_fmt yuv420p -g 5 -bf 0 $META clip-h264.mov
|
||||
$FF $VID -c:v libx265 -pix_fmt yuv420p -x265-params log-level=error:keyint=5 -tag:v hvc1 $META clip-hevc.mp4
|
||||
$FF $VID $TONE -c:v libvpx-vp9 -pix_fmt yuv420p -g 5 -b:v 100k -c:a libopus -b:a 32k -shortest $META clip-vp9.webm
|
||||
$FF -f rawvideo -pix_fmt rgb24 -s 64x64 -r 10 -i "$TMP_DIR/deckcraft-fixture-64x64.rgb" -c:v libsvtav1 -pix_fmt yuv420p -g 5 -svtav1-params loglevel=0 $META clip-av1.mkv
|
||||
$FF -f rawvideo -pix_fmt rgb24 -s 64x64 -r 10 -i /tmp/deckcraft-fixture-64x64.rgb -c:v libsvtav1 -pix_fmt yuv420p -g 5 -svtav1-params loglevel=0 $META clip-av1.mkv
|
||||
|
||||
@@ -390,7 +390,6 @@ impl Canvas {
|
||||
return;
|
||||
}
|
||||
raw.set_region(self.clip.clone());
|
||||
let region_cost = self.clip.as_ref().map_or(0, |c| c.len() * std::mem::size_of::<[f64; 4]>());
|
||||
let cost = match &raw {
|
||||
Raw::Path { ops, .. } => ops.len() * std::mem::size_of::<Op>(),
|
||||
// A tile shared with its brush is counted once (by the brush); one made for this fill
|
||||
@@ -401,7 +400,7 @@ impl Canvas {
|
||||
}
|
||||
Raw::Bitmap { img, .. } => img.px.len(),
|
||||
Raw::Text { text: t, .. } => t.text.len() + t.dx.len() * 4 + t.font.family.len() + 128,
|
||||
} + region_cost;
|
||||
};
|
||||
if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES {
|
||||
self.full = true;
|
||||
} else {
|
||||
|
||||
@@ -100,14 +100,8 @@ fn is_rect(clip: &[P]) -> bool {
|
||||
|
||||
/// Sutherland–Hodgman: `poly` cut by convex `clip`.
|
||||
pub(crate) fn clip_polygon(poly: &[P], clip: &[P]) -> Vec<P> {
|
||||
if poly.len().saturating_mul(clip.len()) > 1_000_000 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut poly = poly.to_vec();
|
||||
for i in 0..clip.len() {
|
||||
if poly.len().saturating_mul(clip.len() - i) > 1_000_000 {
|
||||
return Vec::new();
|
||||
}
|
||||
let (a, b) = (clip[i], clip[(i + 1) % clip.len()]);
|
||||
let keep = |p: P| cross(a, b, p) >= 0.0;
|
||||
let cut = |p: P, q: P| {
|
||||
@@ -313,10 +307,6 @@ fn crop(rect: [f64; 4], img: Rgba, clip: &[P]) -> Option<([f64; 4], Rgba)> {
|
||||
return None;
|
||||
}
|
||||
let mask = !is_rect(clip);
|
||||
let total_pixels = (x1 - x0).saturating_mul(y1 - y0);
|
||||
if mask && total_pixels.saturating_mul(clip.len()) > 5_000_000 {
|
||||
return None;
|
||||
}
|
||||
let mut px = Vec::with_capacity((x1 - x0) * (y1 - y0) * 4);
|
||||
for row in y0..y1 {
|
||||
// Rows are top-down unless the image says otherwise.
|
||||
|
||||
@@ -222,7 +222,6 @@ pub(crate) struct Plus {
|
||||
/// Convex device clip polygon; None is unclipped.
|
||||
clip: Area,
|
||||
objects: Vec<Option<Obj>>,
|
||||
object_bytes: usize,
|
||||
stack: Vec<Saved>,
|
||||
partial: Option<Partial>,
|
||||
/// The picture frame in device units (`[x, y, w, h]`), what Clear paints.
|
||||
@@ -238,7 +237,6 @@ impl Plus {
|
||||
page: 1.0,
|
||||
clip: None,
|
||||
objects: vec![None; MAX_OBJECTS],
|
||||
object_bytes: 0,
|
||||
stack: Vec::new(),
|
||||
partial: None,
|
||||
frame: None,
|
||||
@@ -249,16 +247,14 @@ impl Plus {
|
||||
pub fn comment(&mut self, cv: &mut Canvas, data: Bytes) {
|
||||
let mut pos = 0usize;
|
||||
while !cv.full() {
|
||||
let Some(tail) = data.0.get(pos..) else { break };
|
||||
let tail = Bytes(tail);
|
||||
let (Some(typ), Some(flags), Some(size), Some(dsize)) = (tail.u16(0), tail.u16(2), tail.u32(4), tail.u32(8)) else {
|
||||
let (Some(typ), Some(flags), Some(size), Some(dsize)) = (data.u16(pos), data.u16(pos + 2), data.u32(pos + 4), data.u32(pos + 8)) else {
|
||||
break;
|
||||
};
|
||||
let (size, dsize) = (size as usize, dsize as usize);
|
||||
if size < 12 || dsize > size - 12 {
|
||||
break;
|
||||
}
|
||||
let Some(rec) = tail.slice(12, dsize) else { break };
|
||||
let Some(rec) = data.slice(pos + 12, dsize) else { break };
|
||||
let _ = self.record(cv, typ, flags, Bytes(rec));
|
||||
let Some(next) = pos.checked_add(size) else { break };
|
||||
pos = next;
|
||||
@@ -294,9 +290,6 @@ impl Plus {
|
||||
let fill = self.brush_of(flags, r.u32(0)?)?;
|
||||
let p = self.painter();
|
||||
for rc in rects(r, 8, r.u32(4)? as usize, compressed)? {
|
||||
if cv.full() {
|
||||
break;
|
||||
}
|
||||
p.fill(cv, &rect_ops(rc), &fill);
|
||||
}
|
||||
Some(())
|
||||
@@ -305,9 +298,6 @@ impl Plus {
|
||||
let pen = self.pen(id)?;
|
||||
let p = self.painter();
|
||||
for rc in rects(r, 4, r.u32(0)? as usize, compressed)? {
|
||||
if cv.full() {
|
||||
break;
|
||||
}
|
||||
p.stroke(cv, &rect_ops(rc), pen);
|
||||
}
|
||||
Some(())
|
||||
@@ -560,28 +550,8 @@ impl Plus {
|
||||
}
|
||||
|
||||
fn store(&mut self, id: usize, obj: Option<Obj>) {
|
||||
let Some(slot) = self.objects.get_mut(id) else { return };
|
||||
if let Some(old) = slot.take() {
|
||||
self.object_bytes = self.object_bytes.saturating_sub(Self::obj_cost(&old));
|
||||
}
|
||||
if let Some(new_obj) = obj {
|
||||
let cost = Self::obj_cost(&new_obj);
|
||||
if self.object_bytes.saturating_add(cost) <= crate::MAX_BYTES {
|
||||
self.object_bytes += cost;
|
||||
*slot = Some(new_obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn obj_cost(obj: &Obj) -> usize {
|
||||
match obj {
|
||||
Obj::Image(img) => match img.as_ref() {
|
||||
ImageData::Bitmap(rgba) => rgba.px.len(),
|
||||
ImageData::Meta(pic) => pic.items.len().saturating_mul(128),
|
||||
},
|
||||
Obj::Brush(Fill::Texture { img, .. }) => img.px.len(),
|
||||
Obj::Path(ops) => ops.len().saturating_mul(std::mem::size_of::<Op>()),
|
||||
_ => 0,
|
||||
if let Some(slot) = self.objects.get_mut(id) {
|
||||
*slot = obj;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1312,18 +1282,11 @@ fn decode_compressed(bytes: &[u8]) -> Option<Rgba> {
|
||||
limits.max_image_height = Some(1 << 15);
|
||||
limits.max_alloc = Some((crate::MAX_PIXELS * 4) as u64);
|
||||
reader.limits(limits);
|
||||
let (w, h) = reader.into_dimensions().ok()?;
|
||||
let (w, h) = (w as usize, h as usize);
|
||||
if w == 0 || h == 0 || w.checked_mul(h)? > crate::MAX_PIXELS {
|
||||
let img = reader.decode().ok()?.to_rgba8();
|
||||
let (w, h) = (img.width() as usize, img.height() as usize);
|
||||
if w.checked_mul(h)? > crate::MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(1 << 15);
|
||||
limits.max_image_height = Some(1 << 15);
|
||||
limits.max_alloc = Some((crate::MAX_PIXELS * 4) as u64);
|
||||
reader.limits(limits);
|
||||
let img = reader.decode().ok()?.to_rgba8();
|
||||
Some(Rgba { w, h, bottom_up: false, px: img.into_raw() })
|
||||
}
|
||||
|
||||
@@ -1443,61 +1406,31 @@ fn wrap_line(line: &[u16], width: f64, em: f64) -> Vec<Vec<u16>> {
|
||||
if em <= 0.0 || !width.is_finite() {
|
||||
return vec![line.to_vec()];
|
||||
}
|
||||
let trimmed = |v: &[u16]| v.iter().rposition(|u| *u != 0x20).map_or(0, |i| i + 1);
|
||||
let measure = |v: &[u16]| v.iter().map(|u| char_em(*u) * em).sum::<f64>();
|
||||
|
||||
let fits = |l: &[u16]| l.iter().map(|u| char_em(*u) * em).sum::<f64>() <= width;
|
||||
let mut out: Vec<Vec<u16>> = Vec::new();
|
||||
let mut cur: Vec<u16> = Vec::new();
|
||||
let mut cur_total_w = 0.0;
|
||||
|
||||
for word in line.split_inclusive(|u| *u == 0x20) {
|
||||
let n_non_space = trimmed(word);
|
||||
let word_non_space_w = measure(&word[..n_non_space]);
|
||||
let word_total_w = measure(word);
|
||||
|
||||
if cur.is_empty() {
|
||||
if word_non_space_w <= width {
|
||||
cur.extend_from_slice(word);
|
||||
cur_total_w = word_total_w;
|
||||
continue;
|
||||
}
|
||||
} else if cur_total_w + word_non_space_w <= width {
|
||||
cur.extend_from_slice(word);
|
||||
cur_total_w += word_total_w;
|
||||
let mut cand = cur.clone();
|
||||
cand.extend_from_slice(word);
|
||||
let trimmed = |v: &[u16]| v.iter().rposition(|u| *u != 0x20).map_or(0, |i| i + 1);
|
||||
if fits(&cand[..trimmed(&cand)]) || cur.is_empty() && fits(&word[..trimmed(word)]) {
|
||||
cur = cand;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !cur.is_empty() {
|
||||
let n = trimmed(&cur);
|
||||
cur.truncate(n);
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
|
||||
if word_non_space_w <= width {
|
||||
cur.extend_from_slice(word);
|
||||
cur_total_w = word_total_w;
|
||||
continue;
|
||||
}
|
||||
|
||||
// A word wider than the line on its own: break it between characters.
|
||||
let mut cur_w = 0.0;
|
||||
for &u in word {
|
||||
let u_w = char_em(u) * em;
|
||||
if cur.is_empty() {
|
||||
cur.push(u);
|
||||
cur_w = u_w;
|
||||
} else if cur_w + u_w <= width {
|
||||
cur.push(u);
|
||||
cur_w += u_w;
|
||||
} else {
|
||||
let n = trimmed(&cur);
|
||||
cur.truncate(n);
|
||||
cur.push(u);
|
||||
if !fits(&cur[..trimmed(&cur)]) && cur.len() > 1 {
|
||||
cur.pop();
|
||||
out.push(std::mem::take(&mut cur));
|
||||
cur.push(u);
|
||||
cur_w = u_w;
|
||||
}
|
||||
}
|
||||
cur_total_w = cur_w;
|
||||
}
|
||||
let n = cur.iter().rposition(|u| *u != 0x20).map_or(0, |i| i + 1);
|
||||
cur.truncate(n);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
//! Placing a picture on a target (a page or a device): the fitted transform, paths as kurbo paths in
|
||||
//! target units and bitmaps as target rectangles. The rasteriser and the PDF writer only paint the result.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{Affine, BezPath, Rect, Shape};
|
||||
|
||||
use crate::{Item, Picture, Seg};
|
||||
@@ -13,22 +11,20 @@ pub const MAX_BITMAP_SIDE: u32 = 30_000;
|
||||
const MAX_COORD: f64 = 1e6;
|
||||
/// Smallest placed bitmap side (target units), so the stretch onto it is never singular.
|
||||
const MIN_BITMAP_SIDE: f64 = 1e-6;
|
||||
/// Maximum aggregate path elements retained in placed output.
|
||||
const MAX_PLACED_ELEMENTS: usize = 500_000;
|
||||
|
||||
/// One item of a placed picture, in target units.
|
||||
pub enum PlacedItem {
|
||||
/// A filled and/or stroked path. The stroke width is in target units; 0 is a cosmetic hairline that
|
||||
/// the renderer draws at its minimum width.
|
||||
Path { path: BezPath, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool, region: Option<Arc<BezPath>> },
|
||||
Path { path: BezPath, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool, region: Option<BezPath> },
|
||||
/// `Picture::items[index]`, a bitmap, stretched into `rect`.
|
||||
Bitmap { index: usize, rect: Rect, region: Option<Arc<BezPath>> },
|
||||
Bitmap { index: usize, rect: Rect, region: Option<BezPath> },
|
||||
/// `Picture::items[index]`, a pattern fill: `path` (target units) filled with the item's tile
|
||||
/// repeated; `tile` maps tile pixels to target units.
|
||||
Pattern { index: usize, path: BezPath, even_odd: bool, tile: Affine, region: Option<Arc<BezPath>> },
|
||||
Pattern { index: usize, path: BezPath, even_odd: bool, tile: Affine, region: Option<BezPath> },
|
||||
/// `Picture::items[index]`, a text string: `transform` maps its text space to target units;
|
||||
/// `clip` (target units) bounds what it draws. `region` is the device-context clip.
|
||||
Text { index: usize, transform: Affine, clip: Option<Rect>, region: Option<Arc<BezPath>> },
|
||||
Text { index: usize, transform: Affine, clip: Option<Rect>, region: Option<BezPath> },
|
||||
}
|
||||
|
||||
impl Picture {
|
||||
@@ -57,65 +53,42 @@ impl Picture {
|
||||
let c = tf.as_coeffs();
|
||||
let k = (c[0].abs() + c[3].abs()) / 2.0;
|
||||
let mut out = Vec::with_capacity(self.items.len());
|
||||
let mut total_elements = 0usize;
|
||||
let mut cached_region: Option<(*const [f32; 4], usize, Option<Option<Arc<BezPath>>>)> = None;
|
||||
let mut get_region = |reg: &Option<Vec<[f32; 4]>>| -> Option<Option<Arc<BezPath>>> {
|
||||
let (ptr, len) = match reg {
|
||||
None => (std::ptr::null(), 0),
|
||||
Some(v) => (v.as_ptr(), v.len()),
|
||||
};
|
||||
if let Some((c_ptr, c_len, ref res)) = cached_region
|
||||
&& c_ptr == ptr
|
||||
&& c_len == len
|
||||
{
|
||||
return res.clone();
|
||||
}
|
||||
let res = placed_region(&tf, reg);
|
||||
cached_region = Some((ptr, len, res.clone()));
|
||||
res
|
||||
};
|
||||
|
||||
for (index, item) in self.items.iter().enumerate() {
|
||||
if total_elements >= MAX_PLACED_ELEMENTS {
|
||||
break;
|
||||
}
|
||||
match item {
|
||||
Item::Path { segs, fill, stroke, even_odd, region } => {
|
||||
let path = tf * bez(segs);
|
||||
let Some(region) = get_region(region) else { continue };
|
||||
let Some(region) = placed_region(&tf, region) else { continue };
|
||||
if in_range(path.bounding_box()) {
|
||||
total_elements = total_elements.saturating_add(path.elements().len());
|
||||
let stroke = stroke.map(|(c, w)| (c, f64::from(w) * k));
|
||||
out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd, region });
|
||||
}
|
||||
}
|
||||
Item::Pattern { segs, even_odd, tile: t, region } => {
|
||||
let path = tf * bez(segs);
|
||||
let Some(region) = get_region(region) else { continue };
|
||||
let Some(region) = placed_region(&tf, region) else { continue };
|
||||
let tile = tf
|
||||
* Affine::translate((f64::from(t.origin[0]), f64::from(t.origin[1])))
|
||||
* Affine::scale_non_uniform(f64::from(t.cell[0]), f64::from(t.cell[1]));
|
||||
let det = tile.determinant();
|
||||
if in_range(path.bounding_box()) && det.is_finite() && det.abs() > 1e-12 {
|
||||
total_elements = total_elements.saturating_add(path.elements().len());
|
||||
out.push(PlacedItem::Pattern { index, path, even_odd: *even_odd, tile, region });
|
||||
}
|
||||
}
|
||||
Item::Text(t) => {
|
||||
let transform = tf * Affine::new(t.transform);
|
||||
let Some(region) = get_region(&t.region) else { continue };
|
||||
let Some(region) = placed_region(&tf, &t.region) else { continue };
|
||||
let clip =
|
||||
t.clip.map(|[x0, y0, x1, y1]| tf.transform_rect_bbox(Rect::new(f64::from(x0), f64::from(y0), f64::from(x1), f64::from(y1))));
|
||||
let c = transform.as_coeffs();
|
||||
let origin = Rect::new(c[4], c[5], c[4], c[5]);
|
||||
if in_range(origin) && c.iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD) && clip.is_none_or(in_range) {
|
||||
if in_range(origin) && c.iter().all(|v| v.is_finite()) && clip.is_none_or(in_range) {
|
||||
out.push(PlacedItem::Text { index, transform, clip, region });
|
||||
}
|
||||
}
|
||||
Item::Bitmap { rect: [x, y, w, h], region, .. } => {
|
||||
let (x0, y0) = (f64::from(*x), f64::from(*y));
|
||||
let rect = tf.transform_rect_bbox(Rect::new(x0, y0, x0 + f64::from(*w), y0 + f64::from(*h)));
|
||||
let Some(region) = get_region(region) else { continue };
|
||||
let Some(region) = placed_region(&tf, region) else { continue };
|
||||
if in_range(rect) && rect.width() > MIN_BITMAP_SIDE && rect.height() > MIN_BITMAP_SIDE {
|
||||
out.push(PlacedItem::Bitmap { index, rect, region });
|
||||
}
|
||||
@@ -140,7 +113,7 @@ impl Item {
|
||||
|
||||
/// The device-context clip in target units. `Some(None)` is the default clip. `None` means the
|
||||
/// region does not meet the page, so the item draws nothing.
|
||||
fn placed_region(tf: &Affine, region: &Option<Vec<[f32; 4]>>) -> Option<Option<Arc<BezPath>>> {
|
||||
fn placed_region(tf: &Affine, region: &Option<Vec<[f32; 4]>>) -> Option<Option<BezPath>> {
|
||||
let Some(rects) = region else { return Some(None) };
|
||||
let mut path = BezPath::new();
|
||||
for [x0, y0, x1, y1] in rects {
|
||||
@@ -149,7 +122,7 @@ fn placed_region(tf: &Affine, region: &Option<Vec<[f32; 4]>>) -> Option<Option<A
|
||||
path.extend(r.to_path(0.1));
|
||||
}
|
||||
}
|
||||
(!path.is_empty()).then_some(Some(Arc::new(path)))
|
||||
(!path.is_empty()).then_some(Some(path))
|
||||
}
|
||||
|
||||
/// Segments as a kurbo path. A segment after a close opens a new subpath at the last start point.
|
||||
|
||||
@@ -1217,7 +1217,7 @@ fn savedc_restores_text_colour_and_font() {
|
||||
fn efont(height: i32, esc: i32, weight: i32, italic: u8, charset: u8, face: &str) -> Vec<u8> {
|
||||
let mut v = cat(vec![h32(1), e32(height), e32(0), e32(esc), e32(esc), e32(weight)]);
|
||||
v.extend([italic, 0, 0, charset, 0, 0, 0, 0]);
|
||||
let mut name: Vec<u8> = face.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
let mut name: Vec<u8> = face.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
|
||||
name.resize(64, 0);
|
||||
v.extend(name);
|
||||
erec(82, &v)
|
||||
@@ -2022,27 +2022,3 @@ fn emfplus_clear_paints_the_whole_frame_and_transparent_clears_nothing() {
|
||||
// No frame: nothing to clear.
|
||||
assert_eq!(plus_pic(vec![clear(0xFFFF_FFFF), dot]).items.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emfplus_wrap_line_linear_performance() {
|
||||
let font = prec(0x4008, 0x0600, &font_obj("Arial", 10.0, 0, 0));
|
||||
// Repeated words that wrap across lines
|
||||
let text = "word ".repeat(500);
|
||||
let s = prec(
|
||||
0x401C,
|
||||
0x8000,
|
||||
&cat(vec![h32(0xFF00_0000), h32(9), h32(text.encode_utf16().count() as u32), f32s(&[0.0, 0.0, 100.0, 1000.0]), u16s(&text)]),
|
||||
);
|
||||
let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFFFF_FFFF), h32(1), f32s(&[0.0, 0.0, 100.0, 1000.0])]));
|
||||
let p = plus_pic(vec![fill, font, s]);
|
||||
let text_items = p.items.iter().filter(|i| matches!(i, Item::Text(_))).count();
|
||||
assert!(text_items > 10, "wrapped into multiple lines: {text_items}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_polygon_work_bound() {
|
||||
let poly: Vec<(f64, f64)> = (0..2000).map(|i| (i as f64, i as f64)).collect();
|
||||
let clip: Vec<(f64, f64)> = (0..2000).map(|i| (i as f64, i as f64)).collect();
|
||||
let clipped = crate::cliprect::clip_polygon(&poly, &clip);
|
||||
assert!(clipped.is_empty(), "excessive polygon clip product rejected");
|
||||
}
|
||||
|
||||
@@ -248,46 +248,6 @@ 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 {
|
||||
@@ -646,19 +606,3 @@ 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]);
|
||||
}
|
||||
}
|
||||
|
||||
+21
-88
@@ -609,44 +609,27 @@ impl Chart {
|
||||
return (!secondary && self.kind != ChartType::Combo).then_some((0.0, 0.0));
|
||||
}
|
||||
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
|
||||
let has_stacked_or_percent = ser.iter().any(|s| {
|
||||
let k = self.series_kind(s);
|
||||
k.stacked() || k.percent()
|
||||
});
|
||||
|
||||
let n = ser.iter().map(|s| s.values.len()).max().unwrap_or(0);
|
||||
let mut percent = false;
|
||||
if has_stacked_or_percent {
|
||||
let n = ser.iter().map(|s| s.values.len()).max().unwrap_or(0);
|
||||
let budget = (ser.len() * n).min(10_000_000);
|
||||
let n_clamped = if ser.is_empty() { 0 } else { n.min(budget / ser.len()) };
|
||||
|
||||
for i in 0..n_clamped {
|
||||
let (mut p, mut m, mut stacked) = (0.0, 0.0, false);
|
||||
for s in &ser {
|
||||
let k = self.series_kind(s);
|
||||
let Some(v) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue };
|
||||
if k.percent() {
|
||||
percent = true;
|
||||
} else if k.stacked() {
|
||||
stacked = true;
|
||||
if v >= 0.0 { p += v } else { m += v }
|
||||
} else {
|
||||
hi = hi.max(v);
|
||||
lo = lo.min(v);
|
||||
}
|
||||
}
|
||||
if stacked {
|
||||
hi = hi.max(p);
|
||||
lo = lo.min(m);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for i in 0..n {
|
||||
let (mut p, mut m, mut stacked) = (0.0, 0.0, false);
|
||||
for s in &ser {
|
||||
for v in s.values.iter().flatten().copied().filter(|v| v.is_finite()) {
|
||||
let k = self.series_kind(s);
|
||||
let Some(v) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue };
|
||||
if k.percent() {
|
||||
percent = true;
|
||||
} else if k.stacked() {
|
||||
stacked = true;
|
||||
if v >= 0.0 { p += v } else { m += v }
|
||||
} else {
|
||||
hi = hi.max(v);
|
||||
lo = lo.min(v);
|
||||
}
|
||||
}
|
||||
if stacked {
|
||||
hi = hi.max(p);
|
||||
lo = lo.min(m);
|
||||
}
|
||||
}
|
||||
if !percent {
|
||||
for s in &ser {
|
||||
@@ -732,12 +715,6 @@ 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;
|
||||
@@ -825,7 +802,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 * lo.abs().max(hi.abs());
|
||||
let flat = hi - lo < 1e-12;
|
||||
let pad = if !headroom {
|
||||
0.0
|
||||
} else if flat {
|
||||
@@ -837,15 +814,12 @@ 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);
|
||||
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 {
|
||||
if hi - lo < 1e-12 {
|
||||
// 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(unit) },
|
||||
(None, Some(_)) => lo = if hi > 0.0 { 0.0 } else { hi - hi.abs().max(1.0) },
|
||||
(Some(_), None) if lo < 0.0 => hi = 0.0,
|
||||
_ => hi = lo + unit,
|
||||
_ => hi = lo + lo.abs().max(1.0),
|
||||
}
|
||||
}
|
||||
let n = max_intervals.clamp(1, 50) as f64;
|
||||
@@ -853,10 +827,6 @@ 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 {
|
||||
@@ -874,9 +844,7 @@ 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 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 span = if hi - lo < 1e-12 { hi.abs().max(1.0) } else { hi - lo };
|
||||
let raw = span / 5.0;
|
||||
let mag = 10f64.powf(raw.log10().floor());
|
||||
let norm = raw / mag;
|
||||
@@ -891,11 +859,9 @@ 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 * data_mag { min + step } else { max };
|
||||
let max = if (max - min).abs() < 1e-12 { min + step } else { max };
|
||||
(min, max, step)
|
||||
}
|
||||
|
||||
@@ -903,25 +869,6 @@ 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);
|
||||
@@ -933,20 +880,6 @@ 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)];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! (RFC 6716 §4.3.4 – §4.3.6).
|
||||
|
||||
use super::rate::{Mode, QTHETA_OFFSET, QTHETA_OFFSET_TWOPHASE, get_pulses};
|
||||
use super::tables::{E_MEANS, EBANDS, NB_EBANDS, ORDERY, SHORT_MDCT};
|
||||
use super::tables::*;
|
||||
use crate::range::{BITRES, RangeDecoder, ilog};
|
||||
|
||||
pub const SPREAD_NONE: usize = 0;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! (RFC 6716 §4.3.2).
|
||||
|
||||
use super::rate::MAX_FINE_BITS;
|
||||
use super::tables::{BETA_COEF, BETA_INTRA, E_PROB_MODEL, NB_EBANDS, PRED_COEF, SMALL_ENERGY_ICDF};
|
||||
use super::tables::*;
|
||||
use crate::range::RangeDecoder;
|
||||
|
||||
const LAPLACE_MINP: u32 = 1;
|
||||
|
||||
@@ -12,7 +12,7 @@ use bands::{anti_collapse, denormalise_bands, quant_all_bands, renormalise_vecto
|
||||
use energy::{unquant_coarse_energy, unquant_energy_finalise, unquant_fine_energy};
|
||||
use mdct::ImdctScratch;
|
||||
use rate::{Mode, compute_allocation};
|
||||
use tables::{COMB_GAINS, EBANDS, MAX_LM, NB_EBANDS, OVERLAP, PREEMPH, SHORT_MDCT, SPREAD_ICDF, TAPSET_ICDF, TF_SELECT_TABLE, TRIM_ICDF};
|
||||
use tables::*;
|
||||
|
||||
use crate::range::{BITRES, RangeDecoder};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Pulse cache and bit allocation (RFC 6716 §4.3.3, §4.3.4.1).
|
||||
|
||||
use super::tables::{ALLOC, EBANDS, LOG2_FRAC_TABLE, MAX_LM, NB_EBANDS, OVERLAP};
|
||||
use super::tables::*;
|
||||
use crate::range::{BITRES, RangeDecoder};
|
||||
|
||||
const MAX_PSEUDO: i32 = 40;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
//! Fixed-point NLSF decoding, NLSF→LPC conversion and filter stabilisation
|
||||
//! (RFC 6716 §4.2.7.5, with the RFC 8251 §6/§7 overflow fixes).
|
||||
|
||||
use super::tables::{
|
||||
COS_Q12, LSF_ORDER_NBMB, LSF_ORDER_WB, NLSF_CB_NBMB, NLSF_CB_WB, NLSF_MIN_SPACING_NBMB, NLSF_MIN_SPACING_WB, NLSF_PRED_NBMB, NLSF_PRED_WB,
|
||||
NLSF_PSEL_NBMB, NLSF_PSEL_WB,
|
||||
};
|
||||
use super::tables::*;
|
||||
|
||||
pub const MAX_LPC_ORDER: usize = 16;
|
||||
|
||||
|
||||
@@ -9,14 +9,9 @@ mod resampler;
|
||||
#[allow(dead_code)]
|
||||
pub(crate) mod tables;
|
||||
|
||||
use lpc::{MAX_LPC_ORDER, bwexpander, div32_varq, inverse32_varq, nlsf_decode, nlsf2a, rshift_round, sat16, smlawb, smulwb, smulww};
|
||||
use lpc::*;
|
||||
use resampler::Resampler;
|
||||
use tables::{
|
||||
CB_NB_10, CB_NB_20, CB_WB_10, CB_WB_20, DELTA_GAIN, EXC_LSB, EXC_SIGN, GAIN_MSB, LBRR_FLAGS, LTP_FILTER, LTP_PERIODICITY, LTP_SCALE, LTP_TAPS_0,
|
||||
LTP_TAPS_1, LTP_TAPS_2, MID_ONLY, NLSF_EXT, NLSF_INTERP, NLSF_SEL_NBMB, NLSF_SEL_WB, NLSF_STAGE1, NLSF_STAGE2, PITCH_CONTOUR, PITCH_DELTA,
|
||||
PITCH_HIGH, PITCH_LOW, PULSE_COUNT, RATE_LEVEL, SHELL_2, SHELL_4, SHELL_8, SHELL_16, STEREO_PRED_JOINT, STEREO_WEIGHTS_Q13, TYPE_ACTIVE,
|
||||
TYPE_INACTIVE, UNIFORM3, UNIFORM4, UNIFORM5, UNIFORM8,
|
||||
};
|
||||
use tables::*;
|
||||
|
||||
use crate::range::RangeDecoder;
|
||||
use crate::{Error, Result};
|
||||
|
||||
@@ -29,4 +29,4 @@ wasm-bindgen-test = "0.3"
|
||||
# slide renders (the same bytes, in parallel). The root patch selects the same pinned
|
||||
# backend as craft-pdf; this dependency only enables its native compression feature.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
krilla = { version = "=0.8.2", default-features = false, features = ["rayon"] }
|
||||
krilla = { version = "=0.8.2", features = ["rayon"] }
|
||||
|
||||
+11
-28
@@ -39,7 +39,6 @@ pub const RT_CORE: &str = "http://schemas.openxmlformats.org/package/2006/relati
|
||||
/// The files of a package, keyed by normalised part name (no leading slash, lowercase).
|
||||
pub struct Package {
|
||||
parts: HashMap<String, (String, Vec<u8>)>,
|
||||
ct_cache: std::sync::OnceLock<(HashMap<String, String>, HashMap<String, String>)>,
|
||||
}
|
||||
|
||||
impl Package {
|
||||
@@ -95,7 +94,7 @@ impl Package {
|
||||
total = total.saturating_add(data.len() as u64);
|
||||
parts.insert(name.to_ascii_lowercase(), (name, data));
|
||||
}
|
||||
Ok(Package { parts, ct_cache: std::sync::OnceLock::new() })
|
||||
Ok(Package { parts })
|
||||
}
|
||||
pub fn get(&self, name: &str) -> Option<&[u8]> {
|
||||
self.parts.get(&norm(name)).map(|(_, d)| d.as_slice())
|
||||
@@ -132,29 +131,18 @@ impl Package {
|
||||
}
|
||||
/// Content types: (extension defaults, part overrides).
|
||||
pub fn content_type(&self, part: &str) -> Option<String> {
|
||||
let (overrides, defaults) = self.ct_cache.get_or_init(|| {
|
||||
let mut ov = HashMap::new();
|
||||
let mut def = HashMap::new();
|
||||
if let Some(doc) = self.xml("[Content_Types].xml") {
|
||||
for o in doc.root.children_named("Override") {
|
||||
if let (Some(n), Some(ct)) = (o.attr("PartName"), o.attr("ContentType")) {
|
||||
ov.insert(n.to_ascii_lowercase(), ct.to_string());
|
||||
}
|
||||
}
|
||||
for d in doc.root.children_named("Default") {
|
||||
if let (Some(e), Some(ct)) = (d.attr("Extension"), d.attr("ContentType")) {
|
||||
def.insert(e.to_ascii_lowercase(), ct.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
(ov, def)
|
||||
});
|
||||
let doc = self.xml("[Content_Types].xml")?;
|
||||
let p = format!("/{}", norm(part));
|
||||
if let Some(ct) = overrides.get(&p) {
|
||||
return Some(ct.clone());
|
||||
for o in doc.root.children_named("Override") {
|
||||
if o.attr("PartName").is_some_and(|n| n.to_ascii_lowercase() == p) {
|
||||
return o.attr("ContentType").map(String::from);
|
||||
}
|
||||
}
|
||||
let ext = part.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
|
||||
defaults.get(&ext).cloned()
|
||||
doc.root
|
||||
.children_named("Default")
|
||||
.find(|d| d.attr("Extension").is_some_and(|e| e.eq_ignore_ascii_case(&ext)))
|
||||
.and_then(|d| d.attr("ContentType").map(String::from))
|
||||
}
|
||||
pub fn original_name(&self, name: &str) -> Option<&str> {
|
||||
self.parts.get(&norm(name)).map(|(n, _)| n.as_str())
|
||||
@@ -399,12 +387,7 @@ impl PackageOut {
|
||||
if rels.items.is_empty() {
|
||||
return;
|
||||
}
|
||||
let path = rels_path(part);
|
||||
if let Some(existing) = self.files.iter_mut().find(|(name, ..)| *name == path) {
|
||||
existing.1 = rels.to_xml();
|
||||
} else {
|
||||
self.files.push((path, rels.to_xml(), true));
|
||||
}
|
||||
self.files.push((rels_path(part), rels.to_xml(), true));
|
||||
}
|
||||
/// Add a part with no content type of its own (a relationships part).
|
||||
pub fn raw(&mut self, name: &str, data: Vec<u8>) {
|
||||
|
||||
@@ -5,7 +5,7 @@ use deckcraft_model::style::Fill;
|
||||
|
||||
use crate::xml::El;
|
||||
|
||||
/// Fills of chart parts never reference images we keep; read colour and pattern fills only.
|
||||
/// Fills of chart parts never reference images we keep; read colour fills only.
|
||||
fn simple_fill(sp: Option<&El>) -> Option<Fill> {
|
||||
let sp = sp?;
|
||||
for e in sp.elements() {
|
||||
@@ -13,7 +13,6 @@ 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))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -127,21 +126,23 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
|
||||
let mut kinds: Vec<ChartType> = vec![];
|
||||
// The value axis the first plot group uses is the primary one; a group on another value axis
|
||||
// (a combo chart's line on the right) is on the secondary axis.
|
||||
let val_map: std::collections::HashMap<&str, bool> =
|
||||
let val_ids: Vec<(&str, bool)> =
|
||||
pa.children_named("valAx").filter_map(|a| Some((a.child_val("axId")?, matches!(a.child_val("axPos"), Some("l" | "r"))))).collect();
|
||||
// A group's value axis: its vertical one when it has two (scatter), else its one.
|
||||
let group_val_axis = |g: &El| {
|
||||
let ids: Vec<(&str, bool)> =
|
||||
g.children_named("axId").filter_map(|a| a.attr("val")).filter_map(|id| val_map.get(id).copied().map(|v| (id, v))).collect();
|
||||
g.children_named("axId").filter_map(|a| a.attr("val")).filter_map(|id| val_ids.iter().find(|(v, _)| *v == id).copied()).collect();
|
||||
ids.iter().find(|(_, vertical)| *vertical).or(ids.first()).map(|(id, _)| id.to_string())
|
||||
};
|
||||
// The primary value axis is the one on the left (bottom for bar charts); the secondary one
|
||||
// sits on the right or top, or crosses the category axis at its maximum.
|
||||
let secondary_placed = |a: &El| a.child_val("crosses") == Some("max") || matches!(a.child_val("axPos"), Some("r" | "t"));
|
||||
let val_axes_by_id: std::collections::HashMap<&str, &El> = pa.children_named("valAx").filter_map(|a| Some((a.child_val("axId")?, a))).collect();
|
||||
let used: Vec<String> = pa.elements().filter(|g| g.local().ends_with("Chart")).filter_map(group_val_axis).collect();
|
||||
let primary_val: Option<String> =
|
||||
used.iter().find(|id| val_axes_by_id.get(id.as_str()).is_some_and(|a| !secondary_placed(a))).or(used.first()).cloned();
|
||||
let primary_val: Option<String> = used
|
||||
.iter()
|
||||
.find(|id| pa.children_named("valAx").find(|a| a.child_val("axId") == Some(id.as_str())).is_some_and(|a| !secondary_placed(a)))
|
||||
.or(used.first())
|
||||
.cloned();
|
||||
for g in pa.elements() {
|
||||
let name = g.local();
|
||||
if !name.ends_with("Chart") {
|
||||
@@ -212,11 +213,7 @@ 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);
|
||||
}
|
||||
// `<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) {
|
||||
if g.path(&["dLbls", "showVal"]).and_then(|v| v.bool("val")) == Some(true) {
|
||||
c.data_labels = true;
|
||||
}
|
||||
kinds.push(kind);
|
||||
@@ -233,12 +230,8 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
|
||||
secondary_axis: secondary,
|
||||
..Default::default()
|
||||
};
|
||||
// `<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)) {
|
||||
if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).and_then(|v| v.bool("val")) {
|
||||
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) {
|
||||
@@ -417,21 +410,15 @@ fn text_color(e: Option<&El>) -> Option<deckcraft_model::ColorRef> {
|
||||
|
||||
const MAX_KEPT_PARTS: usize = 256;
|
||||
const MAX_KEPT_DEPTH: usize = 8;
|
||||
pub const MAX_KEPT_TOTAL_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
/// Relationships and parts a chart points at, so a later save can write them back.
|
||||
pub(crate) fn capture_kept(imp: &mut super::Imp, chart_part: &str) -> deckcraft_model::chart::ChartKept {
|
||||
pub(crate) fn capture_kept(pkg: &crate::opc::Package, chart_part: &str) -> deckcraft_model::chart::ChartKept {
|
||||
use deckcraft_model::chart::{ChartKept, ChartRel};
|
||||
|
||||
let key = crate::opc::norm(chart_part);
|
||||
if let Some(cached) = imp.kept_charts.get(&key) {
|
||||
return cached.clone();
|
||||
}
|
||||
|
||||
let mut kept = ChartKept::default();
|
||||
// The chart document itself is rewritten; don't store a second copy of it.
|
||||
let mut seen = std::collections::HashMap::from([(key.clone(), true)]);
|
||||
for r in imp.pkg.rels(chart_part).items {
|
||||
let mut seen = std::collections::HashMap::from([(crate::opc::norm(chart_part), true)]);
|
||||
for r in pkg.rels(chart_part).items {
|
||||
if !safe_rel(&r.id, &r.ty, &r.target_raw) {
|
||||
log::warn!("pptx: skipping chart relationship {}", r.id);
|
||||
continue;
|
||||
@@ -440,11 +427,10 @@ pub(crate) fn capture_kept(imp: &mut super::Imp, chart_part: &str) -> deckcraft_
|
||||
kept.rels.push(ChartRel { id: r.id, rel_type: r.ty, target: r.target_raw, part: String::new(), external: true });
|
||||
continue;
|
||||
}
|
||||
if copy_part(imp.pkg, &r.target, &mut kept.parts, &mut seen, &mut imp.kept_sidecar_bytes, 0) {
|
||||
if copy_part(pkg, &r.target, &mut kept.parts, &mut seen, 0) {
|
||||
kept.rels.push(ChartRel { id: r.id, rel_type: r.ty, target: r.target_raw, part: r.target, external: false });
|
||||
}
|
||||
}
|
||||
imp.kept_charts.insert(key, kept.clone());
|
||||
kept
|
||||
}
|
||||
|
||||
@@ -458,7 +444,6 @@ fn copy_part(
|
||||
part: &str,
|
||||
out: &mut Vec<deckcraft_model::chart::ChartPart>,
|
||||
seen: &mut std::collections::HashMap<String, bool>,
|
||||
retained_bytes: &mut usize,
|
||||
depth: usize,
|
||||
) -> bool {
|
||||
use deckcraft_model::chart::ChartPart;
|
||||
@@ -488,14 +473,7 @@ fn copy_part(
|
||||
return false;
|
||||
}
|
||||
let content_type = pkg.content_type(part).unwrap_or_default();
|
||||
let stored_bytes = if *retained_bytes + bytes.len() <= MAX_KEPT_TOTAL_BYTES {
|
||||
*retained_bytes += bytes.len();
|
||||
bytes.to_vec()
|
||||
} else {
|
||||
log::warn!("pptx: chart sidecar retained byte budget exceeded for {part}");
|
||||
Vec::new()
|
||||
};
|
||||
out.push(ChartPart { name, content_type, bytes: stored_bytes, native_entry: None });
|
||||
out.push(ChartPart { name, content_type, bytes: bytes.to_vec(), native_entry: None });
|
||||
seen.insert(key, true);
|
||||
let rels_name = crate::opc::rels_path(part);
|
||||
if crate::opc::sidecar_part(&rels_name)
|
||||
@@ -504,21 +482,15 @@ fn copy_part(
|
||||
{
|
||||
seen.insert(crate::opc::norm(&rels_name), true);
|
||||
let name = pkg.original_name(&rels_name).unwrap_or(&rels_name).trim_start_matches('/').to_string();
|
||||
let stored_rels = if *retained_bytes + rels_bytes.len() <= MAX_KEPT_TOTAL_BYTES {
|
||||
*retained_bytes += rels_bytes.len();
|
||||
rels_bytes.to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
out.push(ChartPart {
|
||||
name,
|
||||
content_type: "application/vnd.openxmlformats-package.relationships+xml".into(),
|
||||
bytes: stored_rels,
|
||||
bytes: rels_bytes.to_vec(),
|
||||
native_entry: None,
|
||||
});
|
||||
let targets: Vec<String> = pkg.rels(part).items.into_iter().filter(|r| !r.external).map(|r| r.target).collect();
|
||||
for target in targets {
|
||||
copy_part(pkg, &target, out, seen, retained_bytes, depth + 1);
|
||||
copy_part(pkg, &target, out, seen, depth + 1);
|
||||
}
|
||||
}
|
||||
true
|
||||
@@ -585,29 +557,6 @@ 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>"#;
|
||||
|
||||
@@ -9,7 +9,6 @@ use std::collections::HashMap;
|
||||
|
||||
/// Points per level and levels per dimension we keep (guards against hostile parts).
|
||||
const MAX_POINTS: usize = 100_000;
|
||||
const MAX_CHARTEX_POINTS: usize = 1_000_000;
|
||||
|
||||
/// One `cx:data`: its categories (the innermost level) and named numeric dimensions.
|
||||
#[derive(Default)]
|
||||
@@ -21,52 +20,38 @@ struct Data {
|
||||
}
|
||||
|
||||
/// A level's points, by `idx`.
|
||||
fn points(lvl: &El, budget: &mut usize) -> Vec<String> {
|
||||
if *budget == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let cap = MAX_POINTS.min(*budget);
|
||||
let mut pts = Vec::new();
|
||||
let mut max_idx = 0;
|
||||
for pt in lvl.children_named("pt").take(cap) {
|
||||
fn points(lvl: &El) -> Vec<String> {
|
||||
let n = lvl.u32("ptCount").unwrap_or(0).min(MAX_POINTS as u32) as usize;
|
||||
let mut out = vec![String::new(); n];
|
||||
for pt in lvl.children_named("pt").take(MAX_POINTS) {
|
||||
let i = pt.u32("idx").unwrap_or(0) as usize;
|
||||
if i >= cap {
|
||||
if i >= MAX_POINTS {
|
||||
continue;
|
||||
}
|
||||
max_idx = max_idx.max(i + 1);
|
||||
pts.push((i, pt.text()));
|
||||
}
|
||||
if pts.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let n = max_idx.min(cap);
|
||||
let mut out = vec![String::new(); n];
|
||||
for (i, s) in pts {
|
||||
if let Some(target) = out.get_mut(i) {
|
||||
*target = s;
|
||||
if i >= out.len() {
|
||||
out.resize(i + 1, String::new());
|
||||
}
|
||||
if let Some(s) = out.get_mut(i) {
|
||||
*s = pt.text();
|
||||
}
|
||||
}
|
||||
*budget = budget.saturating_sub(out.len());
|
||||
out
|
||||
}
|
||||
|
||||
fn data(d: &El, budget: &mut usize) -> Data {
|
||||
fn data(d: &El) -> Data {
|
||||
let mut out = Data::default();
|
||||
for dim in d.elements().take(64) {
|
||||
if *budget == 0 {
|
||||
break;
|
||||
}
|
||||
for dim in d.elements() {
|
||||
// The first level is the innermost: a treemap's leaves, a waterfall's categories.
|
||||
let Some(lvl) = dim.child("lvl") else { continue };
|
||||
let ty = dim.attr("type").unwrap_or("");
|
||||
if dim.is("strDim") || (dim.is("numDim") && ty == "cat") {
|
||||
if ty == "cat" || out.categories.is_empty() {
|
||||
out.categories = points(lvl, budget);
|
||||
out.categories = points(lvl);
|
||||
// Further levels are the hierarchy's parents, innermost first.
|
||||
out.levels = dim.children_named("lvl").skip(1).take(deckcraft_model::chart::MAX_LEVELS).map(|l| points(l, budget)).collect();
|
||||
out.levels = dim.children_named("lvl").skip(1).take(deckcraft_model::chart::MAX_LEVELS).map(points).collect();
|
||||
}
|
||||
} else if dim.is("numDim") {
|
||||
let vals = points(lvl, budget).into_iter().map(|s| s.trim().parse::<f64>().ok().filter(|v| v.is_finite())).collect();
|
||||
let vals = points(lvl).into_iter().map(|s| s.trim().parse::<f64>().ok().filter(|v| v.is_finite())).collect();
|
||||
out.numbers.insert(ty.to_string(), (vals, lvl.attr("formatCode").map(str::to_string)));
|
||||
}
|
||||
}
|
||||
@@ -100,22 +85,15 @@ pub(crate) fn chartex_from(root: &El) -> Option<Chart> {
|
||||
if !root.is("chartSpace") {
|
||||
return None;
|
||||
}
|
||||
let mut decode_budget = MAX_CHARTEX_POINTS;
|
||||
let datas: HashMap<String, Data> = root
|
||||
.child("chartData")?
|
||||
.children_named("data")
|
||||
.take(64)
|
||||
.map(|d| (d.attr("id").unwrap_or("0").to_string(), data(d, &mut decode_budget)))
|
||||
.collect();
|
||||
let datas: HashMap<String, Data> =
|
||||
root.child("chartData")?.children_named("data").map(|d| (d.attr("id").unwrap_or("0").to_string(), data(d))).collect();
|
||||
let ch = root.child("chart")?;
|
||||
let region = ch.child("plotArea")?.child("plotAreaRegion")?;
|
||||
let mut c = Chart { categories: vec![], series: vec![], title: None, legend: None, gridlines: false, ..Default::default() };
|
||||
c.title = ch.child("title").map(|t| rich_text(t).filter(|s| !s.is_empty()).unwrap_or_else(|| "Chart Title".into()));
|
||||
c.legend = ch.child("legend").map(|l| l.attr("pos").unwrap_or("t").to_string());
|
||||
let mut kind = None;
|
||||
let mut output_budget = MAX_CHARTEX_POINTS;
|
||||
let mut categories_set = false;
|
||||
for s in region.children_named("series").take(1000) {
|
||||
for s in region.children_named("series") {
|
||||
let layout = s.attr("layoutId").unwrap_or("");
|
||||
if layout == "paretoLine" {
|
||||
// The cumulative line PowerPoint adds to a Pareto histogram.
|
||||
@@ -131,23 +109,11 @@ pub(crate) fn chartex_from(root: &El) -> Option<Chart> {
|
||||
_ => k,
|
||||
});
|
||||
let d = datas.get(s.child("dataId").and_then(|i| i.attr("val")).unwrap_or("0"))?;
|
||||
let (values_src, format) = ["val", "size", "colorVal"]
|
||||
.iter()
|
||||
.find_map(|t| d.numbers.get(*t))
|
||||
.or_else(|| d.numbers.values().next())
|
||||
.map(|(v, f)| (v.as_slice(), f.clone()))
|
||||
.unwrap_or((&[], None));
|
||||
let take_len = values_src.len().min(output_budget);
|
||||
let values = values_src[..take_len].to_vec();
|
||||
output_budget = output_budget.saturating_sub(values.len());
|
||||
if !categories_set && (!d.categories.is_empty() || !d.levels.is_empty()) {
|
||||
categories_set = true;
|
||||
let (values, format) =
|
||||
["val", "size", "colorVal"].iter().find_map(|t| d.numbers.get(*t)).or_else(|| d.numbers.values().next()).cloned().unwrap_or_default();
|
||||
if c.categories.is_empty() {
|
||||
c.categories = d.categories.clone();
|
||||
c.category_levels = d.levels.clone();
|
||||
output_budget = output_budget.saturating_sub(c.categories.len());
|
||||
for lvl in &c.category_levels {
|
||||
output_budget = output_budget.saturating_sub(lvl.len());
|
||||
}
|
||||
}
|
||||
if let Some(st) = s.child("layoutPr").and_then(|l| l.child("statistics")) {
|
||||
c.inclusive_quartiles = st.attr("quartileMethod") == Some("inclusive");
|
||||
|
||||
+22
-46
@@ -86,21 +86,16 @@ pub fn fill_el(imp: &mut Imp, part: &Part, f: &El) -> Option<Fill> {
|
||||
Some(p) => Fill::Picture(p),
|
||||
None => Fill::None,
|
||||
},
|
||||
"pattFill" => Fill::Pattern(pattern(f)),
|
||||
"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)),
|
||||
}),
|
||||
"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")
|
||||
@@ -498,11 +493,6 @@ pub fn hyperlink(imp: &mut Imp, part: &Part, h: &El) -> Option<Hyperlink> {
|
||||
})
|
||||
}
|
||||
|
||||
fn typeface(e: &El) -> Option<String> {
|
||||
let t = e.attr("typeface").filter(|t| !t.is_empty())?;
|
||||
Some(t.chars().take(256).collect())
|
||||
}
|
||||
|
||||
pub fn rpr(imp: &mut Imp, part: &Part, r: &El) -> RunProps {
|
||||
let mut p = RunProps {
|
||||
lang: r.attr("lang").map(String::from),
|
||||
@@ -540,10 +530,10 @@ pub fn rpr(imp: &mut Imp, part: &Part, r: &El) -> RunProps {
|
||||
}
|
||||
"highlight" => p.highlight = color(e),
|
||||
"uFill" => p.underline_color = e.child("solidFill").and_then(color),
|
||||
"latin" => p.font = typeface(e),
|
||||
"ea" => p.font_ea = typeface(e),
|
||||
"cs" => p.font_cs = typeface(e),
|
||||
"sym" => p.font_sym = typeface(e),
|
||||
"latin" => p.font = e.attr("typeface").filter(|t| !t.is_empty()).map(String::from),
|
||||
"ea" => p.font_ea = e.attr("typeface").filter(|t| !t.is_empty()).map(String::from),
|
||||
"cs" => p.font_cs = e.attr("typeface").filter(|t| !t.is_empty()).map(String::from),
|
||||
"sym" => p.font_sym = e.attr("typeface").filter(|t| !t.is_empty()).map(String::from),
|
||||
"hlinkClick" => p.link = hyperlink(imp, part, e),
|
||||
"hlinkMouseOver" => p.hover = hyperlink(imp, part, e),
|
||||
_ => {}
|
||||
@@ -574,8 +564,6 @@ pub fn text_body(imp: &mut Imp, part: &Part, tx: &El) -> TextBody {
|
||||
TextBody { body, list_style, paragraphs }
|
||||
}
|
||||
|
||||
const MAX_PARAGRAPH_RUNS: usize = 10_000;
|
||||
|
||||
pub fn paragraph(imp: &mut Imp, part: &Part, p: &El) -> Paragraph {
|
||||
let mut para = Paragraph::default();
|
||||
for e in p.elements() {
|
||||
@@ -593,45 +581,33 @@ pub fn paragraph(imp: &mut Imp, part: &Part, p: &El) -> Paragraph {
|
||||
let text: String = e.child("t").map(|t| t.text()).unwrap_or_default();
|
||||
// A vertical tab or line feed inside text is a line break, as it lays out and as export writes it.
|
||||
for (i, piece) in text.split(['\u{b}', '\n']).enumerate() {
|
||||
if para.runs.len() >= MAX_PARAGRAPH_RUNS {
|
||||
break;
|
||||
}
|
||||
if i > 0 {
|
||||
para.runs.push(Run { text: String::new(), props: props.clone(), kind: RunKind::Break });
|
||||
}
|
||||
if !piece.is_empty() {
|
||||
if para.runs.len() >= MAX_PARAGRAPH_RUNS {
|
||||
break;
|
||||
}
|
||||
para.runs.push(Run { text: piece.to_string(), props: props.clone(), kind: RunKind::Text });
|
||||
}
|
||||
}
|
||||
}
|
||||
"br" => {
|
||||
if para.runs.len() < MAX_PARAGRAPH_RUNS {
|
||||
let props = e.child("rPr").map(|r| rpr(imp, part, r)).unwrap_or_default();
|
||||
para.runs.push(Run { text: String::new(), props, kind: RunKind::Break });
|
||||
}
|
||||
let props = e.child("rPr").map(|r| rpr(imp, part, r)).unwrap_or_default();
|
||||
para.runs.push(Run { text: String::new(), props, kind: RunKind::Break });
|
||||
}
|
||||
"fld" => {
|
||||
if para.runs.len() < MAX_PARAGRAPH_RUNS {
|
||||
let props = e.child("rPr").map(|r| rpr(imp, part, r)).unwrap_or_default();
|
||||
let text = e.child("t").map(|t| t.text()).unwrap_or_default();
|
||||
para.runs.push(Run { text, props, kind: RunKind::Field { field: e.attr("type").unwrap_or("").to_string() } });
|
||||
}
|
||||
let props = e.child("rPr").map(|r| rpr(imp, part, r)).unwrap_or_default();
|
||||
let text = e.child("t").map(|t| t.text()).unwrap_or_default();
|
||||
para.runs.push(Run { text, props, kind: RunKind::Field { field: e.attr("type").unwrap_or("").to_string() } });
|
||||
}
|
||||
"m" => {
|
||||
if para.runs.len() < MAX_PARAGRAPH_RUNS {
|
||||
// Office Math (a14:m) — keep the OMML and its plain text.
|
||||
let mut text = String::new();
|
||||
let mut ts = vec![];
|
||||
e.find_all("t", &mut ts);
|
||||
for t in ts {
|
||||
text.push_str(&t.text());
|
||||
}
|
||||
let omml = e.elements().map(|c| part.keep_inner(c)).collect::<String>();
|
||||
para.runs.push(Run { text, props: RunProps::default(), kind: RunKind::Math { omml } });
|
||||
// Office Math (a14:m) — keep the OMML and its plain text.
|
||||
let mut text = String::new();
|
||||
let mut ts = vec![];
|
||||
e.find_all("t", &mut ts);
|
||||
for t in ts {
|
||||
text.push_str(&t.text());
|
||||
}
|
||||
let omml = e.elements().map(|c| part.keep_inner(c)).collect::<String>();
|
||||
para.runs.push(Run { text, props: RunProps::default(), kind: RunKind::Math { omml } });
|
||||
}
|
||||
"endParaRPr" => para.end_props = rpr(imp, part, e),
|
||||
_ => {}
|
||||
|
||||
+16
-44
@@ -36,10 +36,6 @@ pub struct Imp<'a> {
|
||||
pub table_style_ids: Vec<String>,
|
||||
/// Per legacy VML drawing part: shape id → preview picture part (see `shapes::vml_preview`).
|
||||
pub vml_previews: HashMap<String, HashMap<String, String>>,
|
||||
/// Aggregate byte count of retained chart sidecars across the presentation.
|
||||
pub kept_sidecar_bytes: usize,
|
||||
/// Cached kept sidecars by normalized chart part name.
|
||||
pub kept_charts: HashMap<String, deckcraft_model::chart::ChartKept>,
|
||||
}
|
||||
|
||||
impl Imp<'_> {
|
||||
@@ -101,32 +97,29 @@ impl Part {
|
||||
|
||||
/// Prefixes declared below the root but not on it, skipping any bound to different namespaces.
|
||||
fn inner_decls(root: &El, ns: &[(String, String)]) -> Vec<(String, String)> {
|
||||
fn rec(e: &El, out: &mut Vec<(String, String)>, out_map: &mut HashMap<String, String>, clash: &mut HashSet<String>, depth: usize) {
|
||||
fn rec(e: &El, out: &mut Vec<(String, String)>, clash: &mut HashSet<String>, depth: usize) {
|
||||
if depth > crate::xml::MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for (k, v) in e.attrs.iter().filter(|(k, _)| k.starts_with("xmlns:")) {
|
||||
if let Some(existing) = out_map.get(k) {
|
||||
if existing != v {
|
||||
match out.iter().find(|(o, _)| o == k).map(|(_, u)| u != v) {
|
||||
Some(true) => {
|
||||
clash.insert(k.clone());
|
||||
}
|
||||
} else if out.len() < 1000 {
|
||||
out_map.insert(k.clone(), v.clone());
|
||||
out.push((k.clone(), v.clone()));
|
||||
Some(false) => {}
|
||||
None => out.push((k.clone(), v.clone())),
|
||||
}
|
||||
}
|
||||
for c in e.elements() {
|
||||
rec(c, out, out_map, clash, depth + 1);
|
||||
rec(c, out, clash, depth + 1);
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
let mut out_map = HashMap::new();
|
||||
let mut clash = HashSet::new();
|
||||
let root_ns: HashSet<&str> = ns.iter().map(|(r, _)| r.as_str()).collect();
|
||||
for c in root.elements() {
|
||||
rec(c, &mut out, &mut out_map, &mut clash, 1);
|
||||
rec(c, &mut out, &mut clash, 1);
|
||||
}
|
||||
out.retain(|(k, _)| !clash.contains(k) && !root_ns.contains(k.as_str()));
|
||||
out.retain(|(k, _)| !clash.contains(k) && !ns.iter().any(|(r, _)| r == k));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -387,8 +380,6 @@ pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec<
|
||||
custom_show_names: HashMap::new(),
|
||||
table_style_ids: Vec::new(),
|
||||
vml_previews: HashMap::new(),
|
||||
kept_sidecar_bytes: 0,
|
||||
kept_charts: HashMap::new(),
|
||||
};
|
||||
let part = Part::new(&pkg, &theme_path, &doc);
|
||||
let theme = theme::read_theme(&mut imp, &part, &doc.root);
|
||||
@@ -418,9 +409,9 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
|
||||
.child("sldMasterIdLst")
|
||||
.map(|l| l.children_named("sldMasterId").filter_map(|m| pres_part.rels.target(m.attr("r:id").unwrap_or(""))).map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
let mut master_paths: Vec<String> =
|
||||
let master_paths: Vec<String> =
|
||||
if master_paths.is_empty() { pres_part.rels.all_of("slideMaster").map(|r| r.target.clone()).collect() } else { master_paths };
|
||||
let mut slide_entries: Vec<(u32, String)> = pres_el
|
||||
let slide_entries: Vec<(u32, String)> = pres_el
|
||||
.child("sldIdLst")
|
||||
.map(|l| {
|
||||
l.children_named("sldId")
|
||||
@@ -432,21 +423,10 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
|
||||
|
||||
let mut docs: HashMap<String, Doc> = HashMap::new();
|
||||
let mut max_id = 0u32;
|
||||
let mut seen_masters = HashSet::new();
|
||||
master_paths.retain(|m| seen_masters.insert(opc::norm(m)));
|
||||
|
||||
let mut all_parts: Vec<String> = master_paths.clone();
|
||||
let mut seen_layouts = HashSet::new();
|
||||
for m in &master_paths {
|
||||
for r in pkg.rels(m).all_of("slideLayout") {
|
||||
let key = opc::norm(&r.target);
|
||||
if seen_layouts.insert(key) {
|
||||
all_parts.push(r.target.clone());
|
||||
}
|
||||
}
|
||||
all_parts.extend(pkg.rels(m).all_of("slideLayout").map(|r| r.target.clone()));
|
||||
}
|
||||
let mut seen_slides = HashSet::new();
|
||||
slide_entries.retain(|(_, p)| seen_slides.insert(opc::norm(p)));
|
||||
all_parts.extend(slide_entries.iter().map(|(_, p)| p.clone()));
|
||||
for p in &all_parts {
|
||||
let key = opc::norm(p);
|
||||
@@ -468,8 +448,6 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
|
||||
custom_show_names: HashMap::new(),
|
||||
table_style_ids: Vec::new(),
|
||||
vml_previews: HashMap::new(),
|
||||
kept_sidecar_bytes: 0,
|
||||
kept_charts: HashMap::new(),
|
||||
};
|
||||
// Slide ids first so hyperlinks anywhere can point at slides.
|
||||
let mut slide_ids: Vec<(u32, String, SlideId)> = vec![];
|
||||
@@ -538,23 +516,17 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
|
||||
|
||||
// Slides.
|
||||
let mut file_slide_ids: HashMap<u32, SlideId> = HashMap::new();
|
||||
let mut notes_by_target: HashMap<String, TextBody> = HashMap::new();
|
||||
for (file_id, path, id) in &slide_ids {
|
||||
let Some(doc) = docs.get(&opc::norm(path)) else { continue };
|
||||
let part = Part::new(&pkg, path, doc);
|
||||
let layout = part.rels.first_of("slideLayout").and_then(|r| layout_by_part.get(&opc::norm(&r.target)).copied()).unwrap_or(fallback_layout);
|
||||
let mut slide = read_slide(&mut imp, &part, doc, *id, layout);
|
||||
// Notes.
|
||||
if let Some(n) = part.rels.first_of("notesSlide") {
|
||||
let n_key = opc::norm(&n.target);
|
||||
if let Some(cached_notes) = notes_by_target.get(&n_key) {
|
||||
slide.notes = cached_notes.clone();
|
||||
} else if let Some(nd) = load(&pkg, &n.target) {
|
||||
let np = Part::new(&pkg, &n.target, &nd);
|
||||
let notes = read_notes(&mut imp, &np, &nd.root);
|
||||
notes_by_target.insert(n_key, notes.clone());
|
||||
slide.notes = notes;
|
||||
}
|
||||
if let Some(n) = part.rels.first_of("notesSlide")
|
||||
&& let Some(nd) = load(&pkg, &n.target)
|
||||
{
|
||||
let np = Part::new(&pkg, &n.target, &nd);
|
||||
slide.notes = read_notes(&mut imp, &np, &nd.root);
|
||||
}
|
||||
// Comments. A modern part and the legacy part can describe the same threads (PowerPoint
|
||||
// writes both): the modern one wins, its flattened legacy copy is skipped.
|
||||
|
||||
@@ -430,7 +430,7 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
|
||||
&& let Some(bytes) = imp.pkg.xml_bytes(&rel.target)
|
||||
&& let Some(mut chart) = if uri.ends_with("/chartex") { super::read_chartex_xml(bytes) } else { super::read_chart_xml(bytes) }
|
||||
{
|
||||
chart.kept = super::chart::capture_kept(imp, &rel.target);
|
||||
chart.kept = super::chart::capture_kept(imp.pkg, &rel.target);
|
||||
s.kind = ShapeKind::Chart(Box::new(chart));
|
||||
return s;
|
||||
}
|
||||
@@ -516,10 +516,6 @@ fn vml_index(pkg: &crate::opc::Package, vml: &str) -> std::collections::HashMap<
|
||||
/// relationship id, which a save renumbers. Only diagrams whose drawing is missing or empty get
|
||||
/// here, and those are drawn from their data.
|
||||
fn diagram_data(imp: &mut Imp, target: &str) -> Option<deckcraft_model::MediaId> {
|
||||
let norm_target = crate::opc::norm(target);
|
||||
if let Some(&id) = imp.media_by_part.get(&norm_target) {
|
||||
return Some(id);
|
||||
}
|
||||
fn strip(e: &mut El, depth: usize) {
|
||||
if depth > crate::xml::MAX_DEPTH {
|
||||
return;
|
||||
@@ -541,15 +537,11 @@ fn diagram_data(imp: &mut Imp, target: &str) -> Option<deckcraft_model::MediaId>
|
||||
data: std::sync::Arc::new(xml.into_bytes()),
|
||||
link: None,
|
||||
});
|
||||
imp.media_by_part.insert(norm_target, id);
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// SmartArt: read the pre-drawn shapes of its drawing part as a group.
|
||||
fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> Option<Shape> {
|
||||
if depth > 8 {
|
||||
return None;
|
||||
}
|
||||
let rel_ids = gd.child("relIds")?;
|
||||
let dm = part.rels.get(rel_ids.attr("r:dm").unwrap_or(""))?;
|
||||
let data = imp.pkg.xml(&dm.target)?;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Slide transitions (`p:transition`) and animations (`p:timing`).
|
||||
|
||||
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, BuildRole, TextBuild, Transition, build_filled, paragraph_build_roles};
|
||||
use deckcraft_model::{Shape, TextBody, find_shape};
|
||||
use deckcraft_model::Shape;
|
||||
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, TextBuild, Transition};
|
||||
|
||||
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, bool)> = vec![];
|
||||
let mut out: Vec<(Animation, u32, String)> = vec![];
|
||||
for f in found {
|
||||
let c = f.ctn;
|
||||
let class = AnimClass::from_xml(c.attr("presetClass").unwrap_or("entr"));
|
||||
@@ -172,56 +172,15 @@ 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();
|
||||
let build = paragraph.is_some() && by_para.iter().any(|(s, g)| *s == file_id && *g == grp);
|
||||
out.push((a, file_id, grp, build));
|
||||
}
|
||||
// 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() });
|
||||
// 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;
|
||||
}
|
||||
_ => anims.extend(effects.into_iter().cloned()),
|
||||
a.text_build = TextBuild::ByParagraph;
|
||||
a.paragraph = None;
|
||||
}
|
||||
out.push((a, file_id, grp));
|
||||
}
|
||||
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()
|
||||
out.into_iter().map(|(a, _, _)| a).collect()
|
||||
}
|
||||
|
||||
+14
-130
@@ -30,17 +30,7 @@ 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::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 => {
|
||||
Fill::Gradient(_) | Fill::Picture(_) | Fill::Pattern(_) | Fill::Group | Fill::Background => {
|
||||
if let Fill::Gradient(g) = f
|
||||
&& let Some(s) = g.stops.first()
|
||||
{
|
||||
@@ -68,54 +58,14 @@ fn sp_pr(w: &mut W, fill: Option<&Fill>, line: Option<&deckcraft_model::Line>) {
|
||||
w.close("c:spPr");
|
||||
}
|
||||
|
||||
/// `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>) {
|
||||
fn rich(w: &mut W, text: &str) {
|
||||
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");
|
||||
@@ -124,10 +74,9 @@ fn rich(w: &mut W, text: &str, color: Option<&deckcraft_model::ColorRef>) {
|
||||
w.close("c:tx");
|
||||
}
|
||||
|
||||
/// A title (the chart's or an axis's) in the chart's text colour.
|
||||
fn title(w: &mut W, text: &str, c: &Chart) {
|
||||
fn title(w: &mut W, text: &str) {
|
||||
w.open0("c:title");
|
||||
rich(w, text, c.text_color.as_ref());
|
||||
rich(w, text);
|
||||
w.val("c:overlay", 0);
|
||||
w.close("c:title");
|
||||
}
|
||||
@@ -268,7 +217,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, c, d);
|
||||
data_labels(w, 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) {
|
||||
@@ -376,9 +325,8 @@ fn trendline(w: &mut W, t: &Trendline) {
|
||||
w.close("c:trendline");
|
||||
}
|
||||
|
||||
fn data_labels(w: &mut W, c: &Chart, show: bool) {
|
||||
fn data_labels(w: &mut W, show: bool) {
|
||||
w.open0("c:dLbls");
|
||||
tx_pr(w, c);
|
||||
for (n, v) in [
|
||||
("c:showLegendKey", false),
|
||||
("c:showVal", show),
|
||||
@@ -407,13 +355,12 @@ 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, c);
|
||||
title(w, t);
|
||||
}
|
||||
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");
|
||||
@@ -427,13 +374,12 @@ 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, c);
|
||||
title(w, t);
|
||||
}
|
||||
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);
|
||||
@@ -465,7 +411,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, c);
|
||||
title(w, t);
|
||||
}
|
||||
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)),
|
||||
@@ -474,7 +420,6 @@ 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" });
|
||||
@@ -523,7 +468,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, c, true);
|
||||
data_labels(w, true);
|
||||
}
|
||||
match g {
|
||||
Group::Bar => {
|
||||
@@ -631,7 +576,7 @@ pub fn chart_xml(c: &Chart) -> (Vec<u8>, bool) {
|
||||
w.open0("c:chart");
|
||||
match &c.title {
|
||||
Some(t) => {
|
||||
title(&mut w, t, c);
|
||||
title(&mut w, t);
|
||||
w.val("c:autoTitleDeleted", 0);
|
||||
}
|
||||
None => w.val("c:autoTitleDeleted", 1),
|
||||
@@ -709,7 +654,6 @@ 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);
|
||||
@@ -718,7 +662,6 @@ 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)
|
||||
}
|
||||
@@ -807,20 +750,11 @@ pub fn write_kept(pkg: &mut crate::opc::PackageOut, chart_name: &str, c: &Chart,
|
||||
}
|
||||
}
|
||||
pkg.rels(chart_name, &rels);
|
||||
let mut parts_by_norm = std::collections::HashMap::new();
|
||||
for p in &c.kept.parts {
|
||||
parts_by_norm.entry(norm(&p.name)).or_insert(p);
|
||||
}
|
||||
let mut seen_payloads = std::collections::HashSet::new();
|
||||
// The kept parts' own relationships, pointed at the new names.
|
||||
for p in c.kept.parts.iter().filter(|p| kept_payload(&p.name)) {
|
||||
let norm_name = norm(&p.name);
|
||||
if !seen_payloads.insert(norm_name.clone()) {
|
||||
continue;
|
||||
}
|
||||
let Some(new_source) = names.get(&norm_name) else { continue };
|
||||
let Some(new_source) = names.get(&norm(&p.name)) else { continue };
|
||||
let rp = norm(&rels_path(&p.name));
|
||||
let Some(rels_part) = parts_by_norm.get(&rp) else { continue };
|
||||
let Some(rels_part) = c.kept.parts.iter().find(|q| norm(&q.name) == rp) else { continue };
|
||||
let mut out = RelsOut::default();
|
||||
for r in crate::opc::rels_from_bytes(&rels_part.bytes, &p.name).items {
|
||||
if r.external {
|
||||
@@ -923,7 +857,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 c.series_data_labels(s) {
|
||||
if s.data_labels.unwrap_or(c.data_labels) {
|
||||
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");
|
||||
@@ -1037,58 +971,8 @@ 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);
|
||||
|
||||
@@ -637,7 +637,6 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result<Vec<u8>, PptxError> {
|
||||
|
||||
// Comment authors.
|
||||
let mut authors: Vec<(String, String, u32)> = vec![];
|
||||
let mut author_map: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
let mut n_comments = 0u32;
|
||||
|
||||
// Slides.
|
||||
@@ -663,13 +662,13 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result<Vec<u8>, PptxError> {
|
||||
n_comments += 1;
|
||||
let cname = format!("ppt/comments/comment{n_comments}.xml");
|
||||
so.rels.add("comments", &opc::relative(&sname, &cname), false);
|
||||
x.pkg.xml_part(&cname, CT_COMMENTS, comments_xml(&s.comments, &mut authors, &mut author_map));
|
||||
x.pkg.xml_part(&cname, CT_COMMENTS, comments_xml(&s.comments, &mut authors));
|
||||
// The modern threaded part next to the flattened legacy one (issue #70):
|
||||
// PowerPoint 2021+ and DeckCraft read replies and resolved state from it,
|
||||
// older readers keep the legacy part.
|
||||
let mname = format!("ppt/comments/modernComment{n_comments}.xml");
|
||||
let rid = so.rels.add(RT_COMMENTS_MODERN, &opc::relative(&sname, &mname), false);
|
||||
x.pkg.xml_part(&mname, CT_COMMENTS_MODERN, modern_comments_xml(&s.comments, &cname, &mut authors, &mut author_map));
|
||||
x.pkg.xml_part(&mname, CT_COMMENTS_MODERN, modern_comments_xml(&s.comments, &cname, &mut authors));
|
||||
comment_rel = Some(rid);
|
||||
}
|
||||
let xml = slide_xml(&mut x, &mut so, s, comment_rel.as_deref());
|
||||
@@ -829,7 +828,7 @@ fn notes_xml(x: &mut Exp, o: &mut Out, notes: &TextBody) -> Vec<u8> {
|
||||
w.finish()
|
||||
}
|
||||
|
||||
fn comments_xml(list: &[Comment], authors: &mut Vec<(String, String, u32)>, author_map: &mut std::collections::HashMap<String, usize>) -> Vec<u8> {
|
||||
fn comments_xml(list: &[Comment], authors: &mut Vec<(String, String, u32)>) -> Vec<u8> {
|
||||
let mut w = W::new();
|
||||
root_open(&mut w, "p:cmLst", A::new());
|
||||
let mut flat: Vec<&Comment> = vec![];
|
||||
@@ -840,7 +839,7 @@ fn comments_xml(list: &[Comment], authors: &mut Vec<(String, String, u32)>, auth
|
||||
}
|
||||
}
|
||||
for c in flat {
|
||||
let ai = author_slot(c, authors, author_map);
|
||||
let ai = author_slot(c, authors);
|
||||
let idx = match authors.get_mut(ai) {
|
||||
Some(a) => {
|
||||
a.2 += 1;
|
||||
@@ -861,45 +860,32 @@ fn comments_xml(list: &[Comment], authors: &mut Vec<(String, String, u32)>, auth
|
||||
|
||||
/// The author's slot in the shared bucket list, adding them on first use. Legacy parts index the
|
||||
/// list by number, the modern authors part gives every slot a GUID.
|
||||
fn author_slot(c: &Comment, authors: &mut Vec<(String, String, u32)>, map: &mut std::collections::HashMap<String, usize>) -> usize {
|
||||
let author = if c.author.is_empty() { "Author" } else { &c.author };
|
||||
if let Some(&i) = map.get(author) {
|
||||
return i;
|
||||
fn author_slot(c: &Comment, authors: &mut Vec<(String, String, u32)>) -> usize {
|
||||
let author = if c.author.is_empty() { "Author".to_string() } else { c.author.clone() };
|
||||
match authors.iter().position(|(n, _, _)| *n == author) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
let ini = if c.initials.is_empty() { author.chars().filter(|ch| ch.is_alphabetic()).take(2).collect() } else { c.initials.clone() };
|
||||
authors.push((author.clone(), ini, 0));
|
||||
authors.len() - 1
|
||||
}
|
||||
}
|
||||
let ini = if c.initials.is_empty() { author.chars().filter(|ch| ch.is_alphabetic()).take(2).collect() } else { c.initials.clone() };
|
||||
let idx = authors.len();
|
||||
authors.push((author.to_string(), ini, 0));
|
||||
map.insert(author.to_string(), idx);
|
||||
idx
|
||||
}
|
||||
|
||||
/// The modern (p188) threaded-comment part: replies nest and the resolved state is kept, which
|
||||
/// the flattened legacy part cannot express (issue #70).
|
||||
fn modern_comments_xml(
|
||||
list: &[Comment],
|
||||
part: &str,
|
||||
authors: &mut Vec<(String, String, u32)>,
|
||||
author_map: &mut std::collections::HashMap<String, usize>,
|
||||
) -> Vec<u8> {
|
||||
fn modern_comments_xml(list: &[Comment], part: &str, authors: &mut Vec<(String, String, u32)>) -> Vec<u8> {
|
||||
let mut w = W::new();
|
||||
w.open("p188:cmLst", A::new().a("xmlns:p188", NS_P188).a("xmlns:a", NS_A).a("xmlns:r", NS_R));
|
||||
for (ti, c) in list.iter().take(10_000).enumerate() {
|
||||
modern_comment(&mut w, c, part, ti, authors, author_map, 0);
|
||||
modern_comment(&mut w, c, part, ti, authors, 0);
|
||||
}
|
||||
w.close("p188:cmLst");
|
||||
w.finish()
|
||||
}
|
||||
|
||||
fn modern_comment(
|
||||
w: &mut W,
|
||||
c: &Comment,
|
||||
part: &str,
|
||||
ti: usize,
|
||||
authors: &mut Vec<(String, String, u32)>,
|
||||
author_map: &mut std::collections::HashMap<String, usize>,
|
||||
depth: usize,
|
||||
) {
|
||||
let ai = author_slot(c, authors, author_map);
|
||||
fn modern_comment(w: &mut W, c: &Comment, part: &str, ti: usize, authors: &mut Vec<(String, String, u32)>, depth: usize) {
|
||||
let ai = author_slot(c, authors);
|
||||
let author = authors.get(ai).map(|(n, _, _)| n.clone()).unwrap_or_default();
|
||||
let dt = if valid_date(&c.date) { c.date.clone() } else { "2024-01-01T00:00:00.000".into() };
|
||||
let mut a = A::new().a("id", crate::tables::guid(&format!("deckcraft-comment:{part}:{ti}"))).a("authorId", author_guid(&author)).a("created", dt);
|
||||
@@ -913,7 +899,7 @@ fn modern_comment(
|
||||
if depth < 4 && !c.replies.is_empty() {
|
||||
w.open0("p188:replyLst");
|
||||
for (ri, r) in c.replies.iter().take(1000).enumerate() {
|
||||
let ri_ai = author_slot(r, authors, author_map);
|
||||
let ri_ai = author_slot(r, authors);
|
||||
let r_author = authors.get(ri_ai).map(|(n, _, _)| n.clone()).unwrap_or_default();
|
||||
let rdt = if valid_date(&r.date) { r.date.clone() } else { "2024-01-01T00:00:00.000".into() };
|
||||
let mut ra = A::new()
|
||||
|
||||
@@ -852,27 +852,21 @@ fn opaque(
|
||||
// parts re-added under this slide's relationship ids.
|
||||
if let Ok(doc) = crate::xml::parse(xml.as_bytes())
|
||||
&& matches!(doc.root.local(), "graphicFrame" | "sp" | "grpSp" | "cxnSp")
|
||||
{
|
||||
let mut part_map: std::collections::HashMap<&str, (&str, deckcraft_model::MediaId)> = std::collections::HashMap::new();
|
||||
for (id, kind, m) in parts {
|
||||
part_map.entry(id.as_str()).or_insert((kind.as_str(), *m));
|
||||
}
|
||||
if let Some(ids) = crate::read::rel_refs(&doc.root)
|
||||
&& let Some(ids) = crate::read::rel_refs(&doc.root)
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let (kind, m) = part_map.get(r.as_str())?;
|
||||
let (_, kind, m) = parts.iter().find(|(id, ..)| id == r)?;
|
||||
Some((r.clone(), x.media_rel(o, *m, kind)?))
|
||||
})
|
||||
.collect::<Option<std::collections::HashMap<_, _>>>()
|
||||
{
|
||||
let mut root = doc.root;
|
||||
set_rel_ids(&mut root, &ids, 0);
|
||||
ole_fallback(&mut root, x, o, preview);
|
||||
let id = cx.id_for(s);
|
||||
set_cnvpr_id(&mut root, id);
|
||||
w.raw(&root.to_xml());
|
||||
return;
|
||||
}
|
||||
{
|
||||
let mut root = doc.root;
|
||||
set_rel_ids(&mut root, &ids, 0);
|
||||
ole_fallback(&mut root, x, o, preview);
|
||||
let id = cx.id_for(s);
|
||||
set_cnvpr_id(&mut root, id);
|
||||
w.raw(&root.to_xml());
|
||||
return;
|
||||
}
|
||||
log::warn!("pptx: object '{label}' written as its preview picture");
|
||||
let id = cx.id_for(s);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Transitions and the animation timing tree.
|
||||
|
||||
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, BuildRole, TextBuild, Transition, build_filled, paragraph_build_roles};
|
||||
use deckcraft_model::{Shape, ShapeId, ShapeKind, find_shape};
|
||||
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, TextBuild, Transition};
|
||||
use deckcraft_model::{Shape, ShapeKind, find_shape};
|
||||
|
||||
use super::shapes::IdMap;
|
||||
use super::{Exp, Out};
|
||||
@@ -565,68 +565,53 @@ 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 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 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
|
||||
}
|
||||
let grp = {
|
||||
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 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 mut effs = vec![];
|
||||
if by_para {
|
||||
// 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,
|
||||
// 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
|
||||
};
|
||||
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 text_sp && !joins {
|
||||
bld.push((spid, grp, by_para || para_effect));
|
||||
if matches!(shape.kind, ShapeKind::Shape) && shape.text.is_some() {
|
||||
bld.push((spid, grp, by_para));
|
||||
}
|
||||
match a.trigger.and_then(|t| idmap.get(t)) {
|
||||
Some(t) => match triggered.iter_mut().find(|(s, _)| *s == t) {
|
||||
@@ -713,8 +698,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, 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));
|
||||
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));
|
||||
}
|
||||
w.close("p:bldLst");
|
||||
}
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
@@ -437,7 +437,7 @@ fn next_rid(rels: &str) -> String {
|
||||
let mut max = 0u32;
|
||||
let mut rest = rels;
|
||||
while let Some(i) = rest.find("rId") {
|
||||
let digits: String = rest[i + 3..].chars().take_while(char::is_ascii_digit).collect();
|
||||
let digits: String = rest[i + 3..].chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if let Ok(n) = digits.parse::<u32>() {
|
||||
max = max.max(n);
|
||||
}
|
||||
|
||||
+42
-128
@@ -29,70 +29,31 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
/// 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)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)));
|
||||
/// 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);
|
||||
}
|
||||
if c.vary_colors && c.series.len() == 1 && s.fill.is_none() && c.kind != ChartType::Waterfall {
|
||||
return Paint::Color(series_color(ctx, c, pi));
|
||||
return series_color(ctx, c, pi);
|
||||
}
|
||||
paint_of(ctx, s.fill.as_ref(), series_color(ctx, c, si))
|
||||
series_fill(ctx, c, si, Some(s))
|
||||
}
|
||||
|
||||
fn polyline(pts: &[Point]) -> BezPath {
|
||||
@@ -232,17 +193,6 @@ 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.
|
||||
@@ -255,19 +205,9 @@ 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>) {
|
||||
// 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 text_col = c.text_color.as_ref().map(|t| rctx.color(t, None)).unwrap_or(Rgba::rgb(0x59, 0x59, 0x59));
|
||||
let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9);
|
||||
if let Some(f) = &c.chart_fill {
|
||||
crate::paint::fill_path(
|
||||
@@ -295,17 +235,16 @@ 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;
|
||||
// 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()
|
||||
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()
|
||||
} 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(), Paint::Color(series_color(rctx, c, i)), None)).collect()
|
||||
c.category_tree().iter().enumerate().map(|(i, n)| (n.name.clone(), series_color(rctx, c, i))).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(), Paint::Color(series_color(rctx, c, i)), None)).collect()
|
||||
c.categories.iter().enumerate().map(|(i, n)| (n.clone(), series_color(rctx, c, i))).collect()
|
||||
} else {
|
||||
c.series
|
||||
.iter()
|
||||
@@ -313,24 +252,13 @@ 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);
|
||||
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)
|
||||
}
|
||||
(s.name.clone(), if line { series_line(rctx, c, i, s) } else { series_fill(rctx, c, i, Some(s)) })
|
||||
})
|
||||
.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 {
|
||||
@@ -339,15 +267,10 @@ 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, 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));
|
||||
}
|
||||
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));
|
||||
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 {
|
||||
@@ -387,7 +310,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.series_data_labels(s) {
|
||||
if c.data_labels {
|
||||
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(
|
||||
@@ -399,8 +322,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
base,
|
||||
Align::Center,
|
||||
Anchor::Middle,
|
||||
// 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 },
|
||||
Rgba::WHITE,
|
||||
false,
|
||||
);
|
||||
}
|
||||
@@ -457,7 +379,10 @@ 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 = axis_ticks;
|
||||
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)
|
||||
};
|
||||
// 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)| {
|
||||
@@ -480,7 +405,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 = positive_span(max - min);
|
||||
let span = (max - min).max(1e-9);
|
||||
// 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| {
|
||||
@@ -489,13 +414,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) / positive_span(max2 - min2) * plot.height(),
|
||||
(true, Some((min2, max2, _))) => plot.y1 - (v.max(min2).min(max2) - min2) / (max2 - min2).max(1e-9) * 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 = if minor > 0.0 { (((max - min) / minor).round() as i64).clamp(1, 500) } else { 1 };
|
||||
let n = (((max - min) / minor.max(1e-12)).round() as i64).clamp(1, 500);
|
||||
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.
|
||||
@@ -589,10 +514,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) / positive_span(hi - lo) * plot.width());
|
||||
let to_x = |v: f64| xscale.map(|(lo, hi, _)| plot.x0 + (v - lo) / (hi - lo).max(1e-9) * plot.width());
|
||||
if let Some(sc) = xscale {
|
||||
for v in ticks(sc) {
|
||||
let pos = plot.x0 + (v - sc.0) / positive_span(sc.1 - sc.0) * plot.width();
|
||||
let pos = plot.x0 + (v - sc.0) / (sc.1 - sc.0).max(1e-9) * plot.width();
|
||||
label(
|
||||
ctx,
|
||||
rctx,
|
||||
@@ -700,7 +625,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 paint = if waterfall {
|
||||
let fill = if waterfall {
|
||||
let slot = if c.subtotals.contains(&i) {
|
||||
2
|
||||
} else if b < a {
|
||||
@@ -708,17 +633,13 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Paint::Color(series_color(rctx, c, slot))
|
||||
series_color(rctx, c, slot)
|
||||
} else {
|
||||
bar_paint(rctx, c, *si, i, s)
|
||||
bar_fill(rctx, c, *si, i, s)
|
||||
};
|
||||
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));
|
||||
}
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(fill, 1.0));
|
||||
ctx.fill_rect(&r.abs());
|
||||
if waterfall && i + 1 < ncat && !horiz {
|
||||
let y = to_v(b);
|
||||
let mut j = BezPath::new();
|
||||
@@ -735,7 +656,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.series_data_labels(s) {
|
||||
if c.data_labels {
|
||||
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)
|
||||
@@ -775,7 +696,6 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
let stacked = kind.stacked();
|
||||
let area_kind = matches!(kind, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar);
|
||||
let col = if area_kind { series_fill(rctx, c, si, Some(s)) } else { series_line(rctx, c, si, s) };
|
||||
let series_stats = s.error_bars.as_ref().map(|_| deckcraft_model::chart::SeriesStats::of(&s.values));
|
||||
let mut pts = vec![];
|
||||
for i in 0..ncat {
|
||||
let Some(v) = s.values.get(i).copied().flatten() else { continue };
|
||||
@@ -799,7 +719,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
}
|
||||
_ => plot.x0 + band * (slot(i) + 0.5),
|
||||
};
|
||||
if let Some((lo, hi)) = s.error_bars.as_ref().and_then(|e| series_stats.as_ref().and_then(|st| e.range_in(st, i, v))) {
|
||||
if let Some((lo, hi)) = s.error_bars.as_ref().and_then(|e| e.range(&s.values, i, v)) {
|
||||
err_marks.push((x, to_axis(sec, lo), to_axis(sec, hi), base * 0.4, s.error_bars.as_ref().is_some_and(|e| e.end_cap)));
|
||||
}
|
||||
pts.push(Point::new(x, to_axis(sec, v)));
|
||||
@@ -1134,12 +1054,6 @@ 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.series_data_labels(s) || c.kind == ChartType::Funnel;
|
||||
let labels = c.data_labels || 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.series_data_labels(s)
|
||||
if c.data_labels
|
||||
&& 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() && s.line.is_none() && s.style.is_none())
|
||||
&& !(empty_ph && f.opts.edit && s.fill.is_none())
|
||||
{
|
||||
for (i, sp) in geo.paths.iter().enumerate() {
|
||||
if sp.fill == FillMode::None || fade(i).fill <= 0.0 {
|
||||
|
||||
@@ -51,7 +51,7 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||
ctx.push_clip_layer(&r.to_path(0.1));
|
||||
for it in pic.place(r, [0.0; 4]) {
|
||||
match it {
|
||||
PlacedItem::Path { path, fill, stroke, even_odd, region } => clipped(&mut ctx, region.as_deref(), |ctx| {
|
||||
PlacedItem::Path { path, fill, stroke, even_odd, region } => clipped(&mut ctx, region.as_ref(), |ctx| {
|
||||
if let Some(f) = fill {
|
||||
ctx.set_paint(rgba(f));
|
||||
ctx.set_fill_rule(if even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
|
||||
@@ -64,7 +64,7 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}),
|
||||
PlacedItem::Bitmap { index, rect, region } => clipped(&mut ctx, region.as_deref(), |ctx| {
|
||||
PlacedItem::Bitmap { index, rect, region } => clipped(&mut ctx, region.as_ref(), |ctx| {
|
||||
let Some(Some(pm)) = pixmaps.get(index) else { return };
|
||||
let (bw, bh) = (pm.width().max(1) as f64, pm.height().max(1) as f64);
|
||||
ctx.set_paint(vello_cpu::Image {
|
||||
@@ -75,7 +75,7 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||
ctx.fill_rect(&rect);
|
||||
ctx.reset_paint_transform();
|
||||
}),
|
||||
PlacedItem::Pattern { index, path, even_odd, tile, region } => clipped(&mut ctx, region.as_deref(), |ctx| {
|
||||
PlacedItem::Pattern { index, path, even_odd, tile, region } => clipped(&mut ctx, region.as_ref(), |ctx| {
|
||||
let Some(Item::Pattern { tile: t, .. }) = pic.items.get(index) else { return };
|
||||
let key = Arc::as_ptr(t);
|
||||
let (pm, avg) = tiles.entry(key).or_insert_with(|| (premul(t.width, t.height, &t.rgba), average(&t.rgba))).clone();
|
||||
@@ -102,7 +102,7 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||
}
|
||||
}
|
||||
}),
|
||||
PlacedItem::Text { index, transform, clip, region } => clipped(&mut ctx, region.as_deref(), |ctx| {
|
||||
PlacedItem::Text { index, transform, clip, region } => clipped(&mut ctx, region.as_ref(), |ctx| {
|
||||
let Some(Item::Text(t)) = pic.items.get(index) else { return };
|
||||
if let Some(c) = clip {
|
||||
ctx.push_clip_layer(&c.to_path(0.1));
|
||||
@@ -281,14 +281,7 @@ fn draw_text(ctx: &mut RenderContext, t: &Text, m: Affine) {
|
||||
let s = em / g.face.upem.max(1.0);
|
||||
let skew = if italic && !g.face.italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
let gm = at * Affine::translate((g.x, g.y)) * Affine::scale_non_uniform(x_scale, 1.0) * skew * Affine::scale(s);
|
||||
let sc = scale_of(gm);
|
||||
if !sc.is_finite() || sc > 1e5 {
|
||||
continue;
|
||||
}
|
||||
let p: BezPath = gm * path.as_ref().clone();
|
||||
if p.elements().len() > 10_000 || p.bounding_box().width() > 1e6 || p.bounding_box().height() > 1e6 {
|
||||
continue;
|
||||
}
|
||||
ctx.fill_path(&p);
|
||||
if bold && g.face.weight < 600.0 {
|
||||
ctx.set_stroke(kurbo::Stroke::new(em * 0.025 * scale_of(at)).with_join(kurbo::Join::Round));
|
||||
|
||||
@@ -208,7 +208,7 @@ pub(crate) fn draw(
|
||||
}
|
||||
}
|
||||
let unmatched_b =
|
||||
tb.iter().enumerate().filter(|(j, _)| pairs.get(*j).is_none_or(Option::is_none)).flat_map(|(_, tok)| tok.glyphs.iter().copied());
|
||||
tb.iter().enumerate().filter(|(j, _)| pairs.get(*j).is_none_or(|p| p.is_none())).flat_map(|(_, tok)| tok.glyphs.iter().copied());
|
||||
for g in unmatched_b.chain(loose_b) {
|
||||
if let Some((run, gid, pos)) = glyph_at(&lb, g) {
|
||||
draw_glyph(ctx, run, gid, place_b * Affine::translate(pos.to_vec2()), t);
|
||||
|
||||
@@ -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 = crate::chart::positive_span(max - min);
|
||||
let ticks = if step > 0.0 { (((max - min) / step).round() as i64).clamp(1, 20) } else { 1 };
|
||||
let span = (max - min).max(1e-9);
|
||||
let ticks = (((max - min) / step.max(1e-12)).round() as i64).clamp(1, 20);
|
||||
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.series_data_labels(s) {
|
||||
if c.data_labels || s.data_labels == Some(true) {
|
||||
for (i, q) in pts.iter().enumerate() {
|
||||
if let Some(v) = s.values.get(i).copied().flatten() {
|
||||
let t = c.data_label(s, v);
|
||||
|
||||
+27
-44
@@ -153,28 +153,18 @@ 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 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 };
|
||||
let dark_text = ctx.color(&ColorRef::scheme(SchemeSlot::Tx1), None);
|
||||
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((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,
|
||||
}
|
||||
}
|
||||
"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,
|
||||
},
|
||||
"light2" => CellLook {
|
||||
fill: if header { Some(a) } else { None },
|
||||
text: if header { Some(white) } else { None },
|
||||
@@ -197,36 +187,29 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
},
|
||||
"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,
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
"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 {
|
||||
// medium2: strong header, banded light tints, white grid.
|
||||
let strong = header || total || first_col || last_col;
|
||||
let fill = if strong {
|
||||
fill: Some(if header || total || first_col || last_col {
|
||||
a
|
||||
} else if band || band_c {
|
||||
tint(40000)
|
||||
} else {
|
||||
tint(20000)
|
||||
};
|
||||
CellLook {
|
||||
fill: Some(fill),
|
||||
text: Some(if strong { white } else { on(fill) }),
|
||||
bold: strong,
|
||||
border: Some((white, 1.0)),
|
||||
sides: [None; 4],
|
||||
paint: None,
|
||||
}
|
||||
}
|
||||
}),
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-158
@@ -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, PhType, Placeholder, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle, Table, Xfrm};
|
||||
use deckcraft_model::{Chart, Geom, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle, Table, Xfrm};
|
||||
|
||||
fn deck_with(shapes: Vec<Shape>) -> Presentation {
|
||||
let mut p = Presentation::default();
|
||||
@@ -35,33 +35,6 @@ 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 {
|
||||
@@ -367,43 +340,6 @@ 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.
|
||||
@@ -1400,41 +1336,6 @@ 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};
|
||||
@@ -2816,61 +2717,3 @@ 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,25 +64,6 @@ 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.
|
||||
|
||||
@@ -572,10 +572,11 @@ fn alpha_num(n: u32, upper: bool) -> String {
|
||||
if n == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let n = n - 1;
|
||||
let mut n = n - 1;
|
||||
let c = (b'a' + (n % 26) as u8) as char;
|
||||
let reps = (n / 26 + 1).min(50);
|
||||
let s: String = std::iter::repeat_n(c, reps as usize).collect();
|
||||
let reps = n / 26 + 1;
|
||||
n = reps;
|
||||
let s: String = std::iter::repeat_n(c, n as usize).collect();
|
||||
if upper { s.to_uppercase() } else { s }
|
||||
}
|
||||
|
||||
|
||||
@@ -394,7 +394,6 @@ fn autonum_formats() {
|
||||
assert_eq!(autonum("alphaLcParenR", 2), "b)");
|
||||
assert_eq!(autonum("arabicParenBoth", 1), "(1)");
|
||||
assert_eq!(autonum("alphaUcPeriod", 27), "AA.");
|
||||
assert_eq!(autonum("alphaUcPeriod", u32::MAX).len(), 51); // 50 letters + '.'
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -214,12 +214,11 @@ impl Backstage {
|
||||
}
|
||||
|
||||
/// Save As and Change File Type: (id for [`SlideApp::export_as`], label, detail).
|
||||
pub const SAVE_TYPES: [(&str, &str, &str); 7] = [
|
||||
pub const SAVE_TYPES: [(&str, &str, &str); 6] = [
|
||||
("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"),
|
||||
];
|
||||
@@ -389,7 +388,6 @@ 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;
|
||||
@@ -574,8 +572,8 @@ fn template_tiles(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) ->
|
||||
/// File name and folder of a recent path, split as text (nothing on disk is read).
|
||||
pub fn split_path(path: &str) -> (String, String) {
|
||||
let p = std::path::Path::new(path);
|
||||
let name = p.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(path).to_string();
|
||||
let folder = p.parent().and_then(std::path::Path::to_str).unwrap_or("").to_string();
|
||||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(path).to_string();
|
||||
let folder = p.parent().and_then(|f| f.to_str()).unwrap_or("").to_string();
|
||||
(name, folder)
|
||||
}
|
||||
|
||||
@@ -829,7 +827,7 @@ fn info(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) {
|
||||
app.set_status("Copied the path");
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(dir) = std::path::Path::new(p).parent().and_then(std::path::Path::to_str)
|
||||
if let Some(dir) = std::path::Path::new(p).parent().and_then(|d| d.to_str())
|
||||
&& ui.button("Open file location").clicked()
|
||||
{
|
||||
app.open_url(&format!("file:///{}", dir.replace('\\', "/").trim_start_matches('/')));
|
||||
|
||||
@@ -561,20 +561,3 @@ 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");
|
||||
}
|
||||
|
||||
+27
-256
@@ -889,35 +889,34 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) {
|
||||
if let Err(e) = r {
|
||||
app.set_status(e.to_string());
|
||||
}
|
||||
// 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
|
||||
}
|
||||
// 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
|
||||
}
|
||||
(_, 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);
|
||||
@@ -1219,16 +1218,7 @@ 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_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 {
|
||||
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() {
|
||||
@@ -1241,16 +1231,6 @@ 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() {
|
||||
@@ -1503,157 +1483,17 @@ 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| {
|
||||
@@ -2003,44 +1843,6 @@ 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);
|
||||
@@ -2052,37 +1854,6 @@ 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)]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user