mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 02:27:08 -06:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b47195feec | ||
|
|
a2435383b1 | ||
|
|
d01254cf2d | ||
|
|
3f399dd450 | ||
|
|
8f04a999b9 | ||
|
|
f011313a3a | ||
|
|
792829ec41 | ||
|
|
4a1d27a227 | ||
|
|
0b8a4cc23e | ||
|
|
128cb0b883 | ||
|
|
2741d3236b | ||
|
|
e37267f548 | ||
|
|
65564cfc1b | ||
|
|
e34169ff75 | ||
|
|
46dec4a660 | ||
|
|
79088b7060 |
Generated
+28
-28
@@ -892,7 +892,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"cpal",
|
||||
@@ -912,7 +912,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -921,7 +921,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -930,14 +930,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
@@ -947,14 +947,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -979,7 +979,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -991,7 +991,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -1002,7 +1002,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1010,7 +1010,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1019,7 +1019,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1028,7 +1028,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-isobmff"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1036,11 +1036,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-matroska"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1050,7 +1050,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-av1",
|
||||
"deckcraft-h264",
|
||||
@@ -1068,7 +1068,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
@@ -1077,7 +1077,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
@@ -1089,15 +1089,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ogg"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-opus"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"deckcraft-fonts",
|
||||
@@ -1115,7 +1115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1131,7 +1131,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1150,7 +1150,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
@@ -1164,7 +1164,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1186,7 +1186,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1195,7 +1195,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
@@ -5733,7 +5733,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png" width="128" height="128" alt="DeckCraft icon: a presentation screen with a bar chart on an orange tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">DeckCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -132,6 +132,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 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-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort |
|
||||
| 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape |
|
||||
| 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) |
|
||||
@@ -242,6 +243,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|---|---|---|
|
||||
| 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-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged |
|
||||
| 2026-10-18 | trivial | Progress log: thumbnails of huge decks render only the slides in view (#969) |
|
||||
| 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |
|
||||
| 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) |
|
||||
| 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) |
|
||||
|
||||
@@ -525,4 +525,46 @@ mod tests {
|
||||
s.execute("animation.painterApply", &json!({"ids": [id3]})).unwrap();
|
||||
assert!(s.anim_painter.is_some());
|
||||
}
|
||||
|
||||
fn setup_triggers() -> (Session, [u64; 4]) {
|
||||
let mut s = Session::with_new();
|
||||
let mut ids = [0u64; 4];
|
||||
for (slot, x) in ids.iter_mut().zip([10.0, 150.0, 300.0, 450.0]) {
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10.0, 100.0, 100.0]})).unwrap();
|
||||
*slot = r.get("id").unwrap().as_u64().unwrap();
|
||||
}
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
fn triggers(s: &Session) -> Vec<Option<deckcraft_model::ShapeId>> {
|
||||
s.doc().unwrap().current_slide().unwrap().animations.iter().map(|x| x.trigger).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_trigger_shape_clears_the_trigger() {
|
||||
let (mut s, [a, b, c, d]) = setup_triggers();
|
||||
let id = |v: u64| Some(deckcraft_model::ShapeId(v as u32));
|
||||
s.execute("animation.add", &json!({"effect": "fade", "ids": [a]})).unwrap();
|
||||
s.execute("animation.add", &json!({"effect": "fade", "ids": [d]})).unwrap();
|
||||
s.execute("animation.timing", &json!({"index": 0, "trigger": b})).unwrap();
|
||||
s.execute("animation.timing", &json!({"index": 1, "trigger": c})).unwrap();
|
||||
assert_eq!(triggers(&s), vec![id(b), id(c)]);
|
||||
|
||||
s.execute("edit.delete", &json!({"ids": [b]})).unwrap();
|
||||
assert_eq!(triggers(&s), vec![None, id(c)], "only the deleted trigger is cleared");
|
||||
assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 2, "the animated shapes keep their effects");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_group_clears_triggers_of_its_children() {
|
||||
let (mut s, [a, b, c, _]) = setup_triggers();
|
||||
let g = s.execute("arrange.group", &json!({"ids": [b, c]})).unwrap().get("id").unwrap().as_u64().unwrap();
|
||||
s.execute("animation.add", &json!({"effect": "fade", "ids": [a]})).unwrap();
|
||||
s.execute("animation.timing", &json!({"index": 0, "trigger": b})).unwrap();
|
||||
assert_eq!(triggers(&s), vec![Some(deckcraft_model::ShapeId(b as u32))]);
|
||||
|
||||
s.execute("edit.delete", &json!({"ids": [g]})).unwrap();
|
||||
assert_eq!(triggers(&s), vec![None], "the trigger was inside the deleted group");
|
||||
assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,12 +572,24 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.delete", "no slide"))?;
|
||||
let mut before = Vec::new();
|
||||
deckcraft_model::walk(shapes, &mut |s, _| before.push(s.id));
|
||||
let n = remove_shapes(shapes, &ids);
|
||||
// Everything that is gone: the given ids plus group descendants and emptied groups.
|
||||
let mut left = Vec::new();
|
||||
deckcraft_model::walk(shapes, &mut |s, _| left.push(s.id));
|
||||
let mut gone = ids.clone();
|
||||
gone.extend(before.into_iter().filter(|i| !left.contains(i)));
|
||||
// Animations of deleted shapes go too.
|
||||
if sel.target == crate::Target::Slides
|
||||
&& let Some(sl) = doc.slides.get_mut(sel.slide)
|
||||
{
|
||||
Arc::make_mut(sl).animations.retain(|a| !ids.contains(&a.shape));
|
||||
let sl = Arc::make_mut(sl);
|
||||
sl.animations.retain(|a| !gone.contains(&a.shape));
|
||||
// Effects triggered by a deleted shape fall back to the main sequence.
|
||||
for a in sl.animations.iter_mut().filter(|a| a.trigger.is_some_and(|t| gone.contains(&t))) {
|
||||
a.trigger = None;
|
||||
}
|
||||
}
|
||||
sel.shapes.retain(|i| !ids.contains(i));
|
||||
sel.text = None;
|
||||
|
||||
@@ -318,6 +318,14 @@ pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
|
||||
pub(crate) fn usize_param(p: &Value, key: &str) -> Option<usize> {
|
||||
p.get(key).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok())
|
||||
}
|
||||
/// An index param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when
|
||||
/// present but not a valid index (negative, non-integer), so a bad index never acts on the selection.
|
||||
pub(crate) fn opt_usize_param(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_u64().and_then(|v| usize::try_from(v).ok()).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` is not a valid index: {v}"))),
|
||||
}
|
||||
}
|
||||
/// A shape id param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when
|
||||
/// present but not a valid id (negative, non-integer, beyond `u32`), so a bad id never edits the selection.
|
||||
pub(crate) fn id_param(p: &Value, key: &str, cmd: &str) -> Result<Option<ShapeId>> {
|
||||
|
||||
@@ -179,7 +179,7 @@ fn body_placeholder(shapes: &mut [deckcraft_model::Shape]) -> Option<&mut deckcr
|
||||
|
||||
fn index_of(s: &Session, p: &Value) -> Result<usize> {
|
||||
let st = s.doc()?;
|
||||
let i = usize_param(p, "index").unwrap_or(st.selection.slide);
|
||||
let i = opt_usize_param(p, "index", "slide")?.unwrap_or(st.selection.slide);
|
||||
if i >= st.doc.slides.len() {
|
||||
return Err(bad("slide", format!("no slide {i}")));
|
||||
}
|
||||
@@ -187,7 +187,7 @@ fn index_of(s: &Session, p: &Value) -> Result<usize> {
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let indices: Vec<usize> = match usize_param(p, "index") {
|
||||
let indices: Vec<usize> = match opt_usize_param(p, "index", "slide.duplicate")? {
|
||||
Some(i) => vec![i],
|
||||
None => {
|
||||
let st = s.doc()?;
|
||||
@@ -240,7 +240,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
if let Some(a) = p.get("ids").and_then(Value::as_array) {
|
||||
a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).map(SlideId).collect()
|
||||
} else if let Some(i) = usize_param(p, "index") {
|
||||
} else if let Some(i) = opt_usize_param(p, "index", "slide.delete")? {
|
||||
st.doc.slides.get(i).map(|x| vec![x.id]).unwrap_or_default()
|
||||
} else if !st.selection.slides.is_empty() {
|
||||
st.selection.slides.clone()
|
||||
@@ -266,7 +266,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn move_slide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let from = usize_param(p, "from").unwrap_or(s.doc()?.selection.slide);
|
||||
let from = opt_usize_param(p, "from", "slide.move")?.unwrap_or(s.doc()?.selection.slide);
|
||||
let to = usize_param(p, "to").ok_or_else(|| bad("slide.move", "missing `to`"))?;
|
||||
s.edit(|doc, sel| {
|
||||
if from >= doc.slides.len() {
|
||||
@@ -531,7 +531,7 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn section_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let at = usize_param(p, "at").unwrap_or(s.doc()?.selection.slide);
|
||||
let at = opt_usize_param(p, "at", "section.add")?.unwrap_or(s.doc()?.selection.slide);
|
||||
let name = str_param(p, "name").unwrap_or("Untitled Section").to_string();
|
||||
s.edit(|doc, _| {
|
||||
let at = at.min(doc.slides.len().saturating_sub(1));
|
||||
@@ -648,6 +648,25 @@ mod tests {
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn invalid_index_params_are_rejected_not_defaulted_to_selection() {
|
||||
let mut s = Session::with_new();
|
||||
while s.doc().expect("doc").doc.slides.len() < 3 {
|
||||
s.execute("slide.new", &json!({})).expect("new slide");
|
||||
}
|
||||
for bad in [json!(-1), json!(1.5)] {
|
||||
for (cmd, p) in [
|
||||
("slide.delete", json!({"index": bad})),
|
||||
("slide.duplicate", json!({"index": bad})),
|
||||
("slide.move", json!({"from": bad, "to": 0})),
|
||||
("section.add", json!({"at": bad})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} must reject {p}");
|
||||
assert_eq!(s.doc().expect("doc").doc.slides.len(), 3, "{cmd} {p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_keeps_section_order_in_step_with_slides() {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
@@ -44,6 +44,11 @@ pub fn instance_id() -> u64 {
|
||||
*ID.get_or_init(|| {
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
|
||||
// No PIDs and no std::time on wasm (`std::process::id()` and
|
||||
// `SystemTime::now()` panic there): the browser clock stands in.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
h.write_u64(js_sys::Date::now() as u64);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
h.write_u32(std::process::id());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Ok(d) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
|
||||
|
||||
@@ -281,8 +281,8 @@ fn percent_decode(s: &str) -> String {
|
||||
|
||||
/// Resolve a relative target against a base directory into a part name.
|
||||
pub fn resolve(base: &str, target: &str) -> String {
|
||||
let target = percent_decode(target);
|
||||
let target = target.split('#').next().unwrap_or("");
|
||||
let target = percent_decode(target.split('#').next().unwrap_or(""));
|
||||
let target = target.as_str();
|
||||
let mut segs: Vec<&str> = if target.starts_with('/') { vec![] } else { base.split('/').filter(|s| !s.is_empty()).collect() };
|
||||
for s in target.split('/') {
|
||||
match s {
|
||||
@@ -441,6 +441,12 @@ impl PackageOut {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_keeps_encoded_hash_and_strips_fragment() {
|
||||
assert_eq!(resolve("ppt/slides", "../media/a%23b.png"), "ppt/media/a#b.png");
|
||||
assert_eq!(resolve("ppt/slides", "../media/a.png#frag"), "ppt/media/a.png");
|
||||
}
|
||||
|
||||
fn zip_of(entries: &[(&str, usize)]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
+23
-4
@@ -413,6 +413,9 @@ struct ParaShaped {
|
||||
/// Size of the paragraph's first run (spacing percentages, empty lines).
|
||||
lead_size: f64,
|
||||
lead_face: Arc<FontFace>,
|
||||
/// Size of the paragraph mark (`a:endParaRPr`): what an empty last line is sized by.
|
||||
end_size: f64,
|
||||
end_face: Arc<FontFace>,
|
||||
bullet: Option<(String, Style)>,
|
||||
#[allow(dead_code)]
|
||||
level: u8,
|
||||
@@ -709,7 +712,9 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
|
||||
faces: vec![],
|
||||
props: ParaProps::default(),
|
||||
lead_size: 18.0,
|
||||
lead_face: face,
|
||||
lead_face: face.clone(),
|
||||
end_size: 18.0,
|
||||
end_face: face,
|
||||
bullet: None,
|
||||
level: 0,
|
||||
rtl: false,
|
||||
@@ -741,6 +746,9 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
|
||||
let lead_style = make_style(ctx, &lead, scale, opts.prompt_color);
|
||||
let lead_size = lead_style.size;
|
||||
let lead_face = lead_style.face.clone();
|
||||
// The paragraph mark, which ends the last line when no break does.
|
||||
let end_style = make_style(ctx, &resolve::run(ctx, shape, para, ¶.end_props), scale, opts.prompt_color);
|
||||
let (end_size, end_face) = (end_style.size, end_style.face.clone());
|
||||
// Bullet.
|
||||
let bullet = match &props.bullet {
|
||||
Some(Bullet::Char { char }) => {
|
||||
@@ -789,7 +797,7 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
ParaShaped { cells, styles, faces, props, lead_size, lead_face, bullet, level: para.level, rtl }
|
||||
ParaShaped { cells, styles, faces, props, lead_size, lead_face, end_size, end_face, bullet, level: para.level, rtl }
|
||||
}
|
||||
|
||||
/// A face that draws the bullet `label` when the bullet's own can't (circled numbers ①, arrows
|
||||
@@ -1712,10 +1720,21 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
}
|
||||
}
|
||||
if size == 0.0 {
|
||||
let (a, d) = line_metrics(&p.lead_face, p.lead_size);
|
||||
// PowerPoint sizes an empty line by the mark that ends it: the `a:rPr` of the
|
||||
// `a:br` that closes it, or `a:endParaRPr` for the paragraph's last line -- not
|
||||
// by the paragraph's first run (#780). An empty *paragraph* already lands on
|
||||
// `end_props` through `lead`, and keeps the same height here.
|
||||
let brk = p.cells.get(rl.end).filter(|c| c.ch == '\u{b}').and_then(|c| p.styles.get(c.style));
|
||||
let (face, sz) = match brk {
|
||||
Some(st) => (&st.face, st.size),
|
||||
None if rl.end >= p.cells.len() => (&p.end_face, p.end_size),
|
||||
// Anything else empty (a line of tabs alone) keeps the old measure.
|
||||
None => (&p.lead_face, p.lead_size),
|
||||
};
|
||||
let (a, d) = line_metrics(face, sz);
|
||||
asc = a;
|
||||
desc = d;
|
||||
size = p.lead_size;
|
||||
size = sz;
|
||||
}
|
||||
let natural = (asc + desc).max(size * 1.0);
|
||||
let lh = match props.line_spacing {
|
||||
|
||||
@@ -1467,3 +1467,99 @@ fn empty_plain_paragraphs_and_soft_breaks_have_no_bullet_preview() {
|
||||
assert_eq!(l.lines.len(), 2);
|
||||
assert!(l.empty_bullets.is_empty(), "a soft line break is not a new list item");
|
||||
}
|
||||
|
||||
/// A run of `text` at `size` points.
|
||||
fn sized_run(text: &str, size: f64) -> Run {
|
||||
let mut r = Run::new(text);
|
||||
r.props.size = Some(size);
|
||||
r
|
||||
}
|
||||
|
||||
/// A line break whose own run properties are `size` points.
|
||||
fn sized_break(size: f64) -> Run {
|
||||
let mut r = Run { text: String::new(), props: Default::default(), kind: deckcraft_model::text::RunKind::Break };
|
||||
r.props.size = Some(size);
|
||||
r
|
||||
}
|
||||
|
||||
/// The height one line of `size`-point text takes in this shape: the yardstick the empty-line
|
||||
/// tests measure against, so they do not depend on the test font's ascent and descent.
|
||||
fn one_line_height(p: &Presentation, sh: &mut Shape, size: f64) -> f64 {
|
||||
sh.text.as_mut().unwrap().paragraphs = vec![Paragraph { runs: vec![sized_run("X", size)], ..Default::default() }];
|
||||
let l = lay(p, sh, 400.0, 400.0);
|
||||
l.lines[0].bottom - l.lines[0].top
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_line_is_sized_by_the_break_that_ends_it() {
|
||||
// PowerPoint sizes an empty line by the mark that closes it, not by the paragraph's first
|
||||
// run (#780). Case A of the report: 40pt text, a 40pt break, a 10pt break, 40pt text, so
|
||||
// the empty second line is a 10pt line and not a 40pt one.
|
||||
let (p, mut sh) = setup("x", 400.0, 400.0);
|
||||
let (h10, h40, h60, h20) = (
|
||||
one_line_height(&p, &mut sh, 10.0),
|
||||
one_line_height(&p, &mut sh, 40.0),
|
||||
one_line_height(&p, &mut sh, 60.0),
|
||||
one_line_height(&p, &mut sh, 20.0),
|
||||
);
|
||||
let put = |sh: &mut Shape, runs: Vec<Run>| {
|
||||
sh.text.as_mut().unwrap().paragraphs = vec![Paragraph { runs, ..Default::default() }];
|
||||
};
|
||||
|
||||
put(&mut sh, vec![sized_run("A1", 40.0), sized_break(40.0), sized_break(10.0), sized_run("A2", 40.0)]);
|
||||
let a = lay(&p, &sh, 400.0, 400.0);
|
||||
assert_eq!(a.lines.len(), 3, "{:?}", a.lines.iter().map(|l| l.top).collect::<Vec<_>>());
|
||||
let empty_a = a.lines[2].top - a.lines[1].top;
|
||||
assert!((empty_a - h10).abs() < 0.5, "a 10pt break should give a 10pt line ({h10}), got {empty_a}");
|
||||
assert!((empty_a - h40).abs() > 1.0, "and not the 40pt first run's line ({h40})");
|
||||
|
||||
// Case C: a 60pt break closing the empty line of a 20pt paragraph pushes the third line
|
||||
// down, the opposite direction from the first run's size -- so this cannot pass by the old
|
||||
// behaviour under a different font.
|
||||
put(&mut sh, vec![sized_run("C1", 20.0), sized_break(20.0), sized_break(60.0), sized_run("C2", 20.0)]);
|
||||
let c = lay(&p, &sh, 400.0, 400.0);
|
||||
let empty_c = c.lines[2].top - c.lines[1].top;
|
||||
assert!((empty_c - h60).abs() < 0.5, "a 60pt break should give a 60pt line ({h60}), got {empty_c}");
|
||||
assert!((empty_c - h20).abs() > 1.0, "and not the 20pt first run's line ({h20})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_last_line_is_sized_by_the_paragraph_mark() {
|
||||
// Case B: 40pt text and a trailing 40pt break, with the paragraph mark at 10pt. No break
|
||||
// closes the empty last line, so it takes the mark's size.
|
||||
let (p, mut sh) = setup("x", 400.0, 400.0);
|
||||
let (h10, h40) = (one_line_height(&p, &mut sh, 10.0), one_line_height(&p, &mut sh, 40.0));
|
||||
{
|
||||
let t = sh.text.as_mut().unwrap();
|
||||
let mut para = Paragraph { runs: vec![sized_run("B1", 40.0), sized_break(40.0)], ..Default::default() };
|
||||
para.end_props.size = Some(10.0);
|
||||
t.paragraphs = vec![para];
|
||||
}
|
||||
let l = lay(&p, &sh, 400.0, 400.0);
|
||||
assert_eq!(l.lines.len(), 2, "the trailing break leaves an empty last line");
|
||||
let h = l.lines[1].bottom - l.lines[1].top;
|
||||
assert!((h - h10).abs() < 0.5, "a 10pt paragraph mark should give {h10}, got {h}");
|
||||
assert!((h - h40).abs() > 1.0, "and not the 40pt first run's line ({h40})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_paragraph_keeps_its_paragraph_mark_height() {
|
||||
// Case D, the control: an empty *paragraph* already took `end_props` before this change,
|
||||
// through `lead`, and must be unaffected by it.
|
||||
let (p, mut sh) = setup("x", 400.0, 400.0);
|
||||
let h10 = one_line_height(&p, &mut sh, 10.0);
|
||||
{
|
||||
let t = sh.text.as_mut().unwrap();
|
||||
let mut empty = Paragraph::default();
|
||||
empty.end_props.size = Some(10.0);
|
||||
t.paragraphs = vec![
|
||||
Paragraph { runs: vec![sized_run("D1", 40.0)], ..Default::default() },
|
||||
empty,
|
||||
Paragraph { runs: vec![sized_run("D2", 40.0)], ..Default::default() },
|
||||
];
|
||||
}
|
||||
let l = lay(&p, &sh, 400.0, 400.0);
|
||||
assert_eq!(l.lines.len(), 3);
|
||||
let h = l.lines[1].bottom - l.lines[1].top;
|
||||
assert!((h - h10).abs() < 0.5, "a 10pt empty paragraph should give {h10}, got {h}");
|
||||
}
|
||||
|
||||
@@ -37,3 +37,40 @@ fn the_outline_of_a_huge_deck_lays_out_only_what_is_in_view() {
|
||||
assert!(h.query_all_by_role(Role::TextInput).count() < 200);
|
||||
assert_eq!(h.state().session.doc().unwrap().selection.slide, 1500);
|
||||
}
|
||||
|
||||
/// The thumbnail pane of an 800-slide deck renders only the thumbnails in view. When every row
|
||||
/// asked for one, a deck bigger than the 600-entry thumbnail cache evicted its own thumbnails before
|
||||
/// the pane was done, so the pane kept rendering and flashing white forever (#969).
|
||||
#[test]
|
||||
fn the_thumbnail_pane_of_a_huge_deck_renders_only_the_slides_in_view() {
|
||||
let mut h = harness(800);
|
||||
h.run_steps(60);
|
||||
let settled = h.state().textures.thumb_count();
|
||||
assert!(settled > 3 && settled < 60, "{settled} thumbnails rendered");
|
||||
// The visible thumbnails are done: more frames render nothing new.
|
||||
h.run_steps(10);
|
||||
assert_eq!(h.state().textures.thumb_count(), settled);
|
||||
// Scrolled far down, the slides now in view get their thumbnails, and only those.
|
||||
h.hover_at(egui::pos2(100.0, 450.0));
|
||||
h.event(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, -40_000.0),
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
phase: egui::TouchPhase::Move,
|
||||
});
|
||||
h.run_steps(60);
|
||||
let scrolled = h.state().textures.thumb_count();
|
||||
assert!(scrolled > settled && scrolled < 2 * settled + 10, "{scrolled} thumbnails after scrolling (was {settled})");
|
||||
}
|
||||
|
||||
/// The same in the Slide Sorter: only the cells in view render a thumbnail (#969).
|
||||
#[test]
|
||||
fn the_sorter_of_a_huge_deck_renders_only_the_slides_in_view() {
|
||||
let mut h = harness(800);
|
||||
h.state_mut().ui.view = crate::ViewMode::Sorter;
|
||||
h.run_steps(60);
|
||||
let settled = h.state().textures.thumb_count();
|
||||
assert!(settled > 3 && settled < 80, "{settled} thumbnails rendered");
|
||||
h.run_steps(10);
|
||||
assert_eq!(h.state().textures.thumb_count(), settled);
|
||||
}
|
||||
|
||||
@@ -203,13 +203,17 @@ fn place_slides(app: &mut SlideApp, ui: &mut Ui, g: &Grid<'_>, budget: &mut u32,
|
||||
let img = crate::textures::device_pixel_rect(Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(g.tw, g.th)), ppp);
|
||||
rects.push((i, cell));
|
||||
let p = ui.painter();
|
||||
match app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, budget) {
|
||||
// Only cells in view (plus a little margin) ask for a thumbnail, so a deck bigger
|
||||
// than the thumbnail cache does not evict its own thumbnails forever (#969).
|
||||
let near = ui.clip_rect().expand2(vec2(0.0, 2.0 * cell.height())).intersects(cell);
|
||||
let tex = if near { app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, budget) } else { None };
|
||||
match tex {
|
||||
Some(tex) => {
|
||||
p.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
None => {
|
||||
p.rect_filled(img, CornerRadius::ZERO, Color32::WHITE);
|
||||
need_more = true;
|
||||
need_more |= near;
|
||||
}
|
||||
}
|
||||
let id = g.doc.slides.get(i).map(|s| s.id);
|
||||
|
||||
@@ -115,7 +115,11 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
p.text(pos2(row.min.x + 14.0, img.min.y + 20.0), Align2::CENTER_TOP, "★", theme::font(10.0), t.text_faint);
|
||||
}
|
||||
let wpx = (img.width() * ppp).round() as u32;
|
||||
match app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) {
|
||||
// Only rows in view (plus a little margin) ask for a thumbnail: with every row asking, a
|
||||
// deck bigger than the thumbnail cache evicts its own thumbnails and never settles (#969).
|
||||
let near = ui.clip_rect().expand2(vec2(0.0, 2.0 * row.height())).intersects(row);
|
||||
let tex = if near { app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) } else { None };
|
||||
match tex {
|
||||
Some(tex) => {
|
||||
p.image(
|
||||
tex.id(),
|
||||
@@ -126,7 +130,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
None => {
|
||||
p.rect_filled(img, CornerRadius::ZERO, Color32::WHITE);
|
||||
need_more = true;
|
||||
need_more |= near;
|
||||
}
|
||||
}
|
||||
if selected {
|
||||
|
||||
Reference in New Issue
Block a user