Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 b47195feec Release 0.10.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:37:20 -04:00
skylar taylor-barrick a2435383b1 Merge pull request #996 from Silvertree2010/fix/h2800-deleting-a-shape-that-is-an-anim
Clear animation triggers that point at deleted shapes
2026-10-22 07:50:16 -06:00
skylar taylor-barrick d01254cf2d Merge pull request #1071 from Silvertree2010/fix/h923-opc-target-resolution-cuts-a-par
Strip URI fragment before percent-decoding OPC targets
2026-10-22 07:50:09 -06:00
skylar taylor-barrick 3f399dd450 Merge pull request #1063 from Silvertree2010/fix/h886-slide-delete-with-an-invalid-neg
Reject invalid slide index params instead of using the selection
2026-10-22 07:50:01 -06:00
skylar taylor-barrick 8f04a999b9 Merge pull request #790 from Hamza0101/fix/empty-line-height
Text: an empty line takes the size of the mark that ends it
2026-10-22 07:49:55 -06:00
skylar taylor-barrick f011313a3a Merge pull request #1011 from Faabyy/fix/thumbs-visible-only-969
Thumbnails: only render the slides in view (#969)
2026-10-22 07:49:48 -06:00
skylar taylor-barrick 792829ec41 Merge pull request #982 from alexeyvolkoff/pr-fix-wasm-startup
fix(wasm): avoid std services that panic on wasm32-unknown-unknown at…
2026-10-22 07:49:40 -06:00
BFlat 4a1d27a227 Merge pull request #1158 from storytold/readme/app-icon
README: the app icon above the title
2026-10-22 18:01:23 +07:00
bflatastic 0b8a4cc23e README: the app icon above the title 2026-10-22 17:57:31 +07:00
Dylan M. Taylor 128cb0b883 Merge pull request #1139 from storytold/sonar-fix/enforce-https
security: enforce HTTPS on curl downloads (SonarCloud S6506)
2026-10-21 20:13:49 -04:00
Faabyy 2741d3236b Thumbnails: only render the slides in view (#969)
The slide thumbnail pane and the Slide Sorter asked the texture cache for a
thumbnail on every row, in view or not, three or four new ones a frame. The
cache keeps at most 600 thumbnails and drops the 200 oldest beyond that, so
on a deck of more than 600 slides the pane evicted its own thumbnails before
it reached the end: it kept rendering, the rows in view went white again and
again, and it never finished.

Now only rows within the scroll area's clip (plus two rows of margin) ask for
a thumbnail and keep the pane repainting; the others are still laid out, so
scroll height, section headers, clicks and drag-to-reorder are unchanged.
Small decks behave as before.

Co-Authored-By: Claude <noreply@anthropic.com>


# Please enter the commit message for your changes. Lines starting
# with '#' will be kept; you may remove them yourself if you want to.
# An empty message aborts the commit.
#
# interactive rebase in progress; onto a31b990
# Last command done (1 command done):
#    pick 03fce2a Thumbnails: only render the slides in view (#969)
# No commands remaining.
# You are currently rebasing.
#
# Changes to be committed:
#	modified:   ROADMAP.md
#	modified:   crates/ui-egui/src/huge_deck_tests.rs
#	modified:   crates/ui-egui/src/sorter.rs
#	modified:   crates/ui-egui/src/thumbs.rs
#
2026-10-21 06:07:58 +00:00
NetterSilverandClaude Haiku 5.5 e37267f548 Strip URI fragment before percent-decoding OPC targets
Found while testing crates/pptx/src/opc.rs:283-285 (resolve)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 18:36:13 +02:00
NetterSilverandClaude Haiku 5.5 65564cfc1b Reject invalid slide index params instead of using the selection
Found while testing crates/engine/src/cmd/slide.rs:234 (delete); same pattern in index_of :182, move_slide :269, duplicate :193 and section_add :534

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 16:29:18 +02:00
NetterSilverandClaude Haiku 5.5 e34169ff75 Clear animation triggers that point at deleted shapes
Found while testing crates/engine/src/cmd/edit.rs:573-585 (delete)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 20:16:01 +02:00
Alexey 46dec4a660 fix(wasm): avoid std services that panic on wasm32-unknown-unknown at startup
recovery::instance_id() mixed std::process::id() into the instance hash —
on wasm32-unknown-unknown that panics (``no pids on this platform``, and
SystemTime is missing too), so the web app died before its first frame.
On wasm the browser clock (js_sys::Date::now(), already an engine dep for
comment timestamps) stands in.
2026-10-18 15:27:59 +02:00
Hamza 79088b7060 Text: an empty line takes the size of the mark that ends it (#780)
An empty line inside a paragraph -- between two `a:br`, or after a trailing one
-- was laid out at the size of the paragraph's *first run*. PowerPoint sizes it
by the mark that closes the line: the `a:rPr` of the `a:br` that ends it, or
`a:endParaRPr` for the paragraph's last line. Every line below then sits too
high or too low, by the difference; in the reported poster deck the text drifted
by up to 173pt.

`shape_para` resolved one `lead_size`/`lead_face` per paragraph and
`layout_scaled` used it for every line with no measured cells. A break is a
cell (`\u{b}`) and `break_lines` sets a line's `end` to that break's own index,
so the closing mark is already to hand: take its style, falling back to the
paragraph mark (now carried as `end_size`/`end_face`) for a last line that no
break closes. Any other empty line -- a line of tabs alone -- keeps the old
measure, so only the two cases PowerPoint defines change.

An empty *paragraph* already landed on `end_props` through `lead`, and is
unchanged: it is the control case in the report, and the test for it passes both
before and after.

Measured on the reported fixture, second line top against PowerPoint's own
export: case A 166pt -> 130pt (131 expected), case B 166 -> 130, case C 113 ->
161 (160 expected), and the control case D 130 either way. The 1pt that remains
is the offset the control already had.
2026-10-15 12:33:20 +05:00
15 changed files with 303 additions and 45 deletions
Generated
+28 -28
View File
@@ -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
View File
@@ -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"
+4
View File
@@ -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">
+2
View File
@@ -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) |
+42
View File
@@ -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);
}
}
+13 -1
View File
@@ -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;
+8
View File
@@ -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>> {
+24 -5
View File
@@ -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();
+5
View File
@@ -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) {
+8 -2
View File
@@ -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
View File
@@ -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, &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 {
+96
View File
@@ -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
View File
@@ -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);
}
+6 -2
View File
@@ -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);
+6 -2
View File
@@ -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 {