Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 22db9261f9 Release 0.8.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:29:08 -04:00
Brandon ThomasandClaude Opus 5.5 19724866a6 Tests: fix two flakes (recovery lock held by a forked mkfifo; notes check not in yet)
recovery::another_running_instance_keeps_its_entries unlocks the other
instance's lock instead of only dropping it: a mkfifo child spawned by a
parallel test shares the open file until it execs, keeping the flock held
(seen on the FreeBSD VM).

spelling_tests::notes_menu_language_dialog_and_repeated_words waits for
the background notes check before right-clicking (seen on ubuntu CI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:29:08 -04:00
NetterSilverandClaude Haiku 5.5 83f21e7cb9 Fix add overflow panic in table.columnWidth for a huge column index (#922)
Found while testing crates/engine/src/cmd/table.rs:540 (column_width)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-17 18:36:12 -06:00
NetterSilverandClaude Haiku 5.5 bdcbee8d0b Make edit.cut a no-op when only a text caret is set (#920)
Found while testing crates/engine/src/cmd/edit.rs:cut (about line 337) and delete (about line 523)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-17 18:35:36 -06:00
Marco Arnulfo 23144e36fe Contributors: add my name for the About window (#857) 2026-10-17 18:35:13 -06:00
skylar taylor-barrick a178b9955b Merge pull request #849 from MMexicann/fix/pptx-trigger-click-steps
PPTX: preserve separate click steps in trigger sequences
2026-10-17 14:18:58 -06:00
skylar taylor-barrick 2cf4535c5d Merge pull request #939 from skylartaylor/control-input
Close the control port on the first line that isn't a request
2026-10-17 12:14:05 -06:00
Mex 2a4adf3024 Merge branch 'main' into fix/pptx-trigger-click-steps 2026-10-17 13:29:55 +02:00
aiongg 7f1979a1cc Close control connections on the first line that is not a request
A line that is not a JSON object with a string method, including an HTTP
request line or bytes that are not UTF-8, gets one error reply and the
connection closes before anything after it is read.
2026-10-16 16:17:41 +00:00
skylar taylor-barrick 1dc69720db Merge pull request #866 from Silvertree2010/fix/h1968-table-insertrowbelow-insertcolum
Clamp huge index in table.insertRowBelow/insertColumnRight
2026-10-15 23:13:10 -06:00
skylar taylor-barrick 1b64de3499 Merge pull request #816 from albayazid/fix/outline-tables-814
Format: .txt and .md outline includes table cell text (#814)
2026-10-15 22:48:23 -06:00
skylar taylor-barrick ad6be08603 Merge pull request #798 from Silvertree2010/fix/h1435-paste-from-html-text-inside-b-st
Don't paste HTML text as bold when <b> sets font-weight:normal
2026-10-15 22:43:42 -06:00
skylar taylor-barrick e5620b538a Merge pull request #797 from Silvertree2010/fix/h1447-outline-import-deletes-leading-a
Keep leading '-', '*' and '•' text when importing outlines
2026-10-15 22:43:12 -06:00
NetterSilverandClaude Haiku 5.5 1bb408462c Clamp huge index in table.insertRowBelow/insertColumnRight
Found while testing crates/engine/src/cmd/table.rs:291

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 04:09:45 +00:00
skylar taylor-barrick f4731822c9 Merge pull request #854 from dylanmtaylor/ci/add-windows-arm64
ci: add shared Windows ARM64 and nix checks
2026-10-15 21:26:51 -06:00
Dylan M. Taylor f9af68a06f ci: add shared Windows ARM64 and nix checks 2026-10-15 20:48:37 -04:00
skylar taylor-barrick be2f10d8cb Merge pull request #822 from Silvertree2010/fix/h1632-slide-duplicate-appends-the-copy
Keep slide.duplicate copies next to their source in the section
2026-10-15 18:29:50 -06:00
skylar taylor-barrick acea4e3f14 Merge pull request #847 from Silvertree2010/fix/h1767-shape-connect-and-shape-sites-po
Reject shape ids beyond u32 in shape.connect, sites and points
2026-10-15 18:29:04 -06:00
Mex c8dc372519 M6.1: preserve PPTX trigger click steps
Keep later On Click effects and paragraph builds in separate interactive steps.
Add round-trip and headless show regressions.

Contribution on behalf of @MMexicann.
2026-10-15 22:31:32 +02:00
NetterSilverandClaude Haiku 5.5 d7509e7ad6 Reject shape ids beyond u32 in shape.connect, sites and points
Found while testing crates/engine/src/cmd/shape.rs:1154 (shape_id, used by connect/sites); same pattern at crates/engine/src/cmd/points.rs:358-359 and :432-433

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 22:09:59 +02:00
NetterSilverandClaude Haiku 5.5 595d8f17c1 Keep slide.duplicate copies next to their source in the section
Found while testing crates/engine/src/cmd/slide.rs:217-220

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:07:07 +02:00
Sazid Al Bayazid 59d5893793 Format: .txt and .md outline includes table cell text (#814)
slides_to_outline only looked at sh.text, which regular text boxes and
placeholders carry. Tables store their text in cells inside
ShapeKind::Table, so convert to .txt or .md silently skipped every
table cell — with exit 0 and no warning.

The outline now walks into tables: each row becomes a tab-separated
line (multi-paragraph cell text is flattened with spaces). Verified
with the issue's python-pptx repro: the four cell texts appear after
the slide title and text box body.

Tests: a deck with a text box and a 2x2 table — the outline contains
all three text sources.
2026-10-15 18:53:01 +06:00
skylar taylor-barrick 0954289c62 Use the shared PDF writer and report font embedding failures (#708)
Use the pinned shared PDF writer with explicit font embedding errors, preserving app-owned rendering and current main. All seven local gates and fresh Ubuntu/FreeBSD checks passed on the exact integration tree; paired native/WASM PDF bytes, structure, text and pixels match. Original contributor commits are preserved.
2026-10-15 18:51:30 +06:00
NetterSilverandClaude Haiku 5.5 062adfe405 Don't paste HTML text as bold when <b> sets font-weight:normal
Found while testing crates/engine/src/cmd/richclip.rs:187

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:52:15 +00:00
NetterSilverandClaude Haiku 5.5 aa681eafad Keep leading '-', '*' and '•' text when importing outlines
Found while testing crates/format/src/lib.rs:421

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:43:29 +02:00
skylar taylor-barrick 9859da3925 Use the shared PDF writer and report font embedding failures (#708)
Use the pinned shared PDF writer with explicit font embedding errors, preserving app-owned rendering and current main. All seven local gates and fresh Ubuntu/FreeBSD checks passed on the exact integration tree; paired native/WASM PDF bytes, structure, text and pixels match. Original contributor commits are preserved.
2026-10-14 21:20:34 -06:00
26 changed files with 530 additions and 91 deletions
+33
View File
@@ -0,0 +1,33 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
nix flake check --no-build --all-systems
else
echo "No flake.nix present in this repository; skipping check."
fi
+30
View File
@@ -0,0 +1,30 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
# binaries are ARM64, run the CLI natively, and uninstall.
name: Windows ARM64
on:
push:
branches: [release]
pull_request:
paths:
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 3 * * 1"
workflow_dispatch:
concurrency:
group: windows-arm64-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: deckcraft
app-dir: DeckCraft
Generated
+28 -28
View File
@@ -892,7 +892,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "deckcraft"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"arboard",
"cpal",
@@ -911,7 +911,7 @@ dependencies = [
[[package]]
name = "deckcraft-anim"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-geom",
"deckcraft-model",
@@ -920,7 +920,7 @@ dependencies = [
[[package]]
name = "deckcraft-av1"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -929,14 +929,14 @@ dependencies = [
[[package]]
name = "deckcraft-bitstream"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"proptest",
]
[[package]]
name = "deckcraft-cli"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-engine",
"deckcraft-mcp",
@@ -945,14 +945,14 @@ dependencies = [
[[package]]
name = "deckcraft-color"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"serde",
]
[[package]]
name = "deckcraft-engine"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -977,7 +977,7 @@ dependencies = [
[[package]]
name = "deckcraft-fonts"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"epaint_default_fonts",
"harfrust 0.13.3",
@@ -989,7 +989,7 @@ dependencies = [
[[package]]
name = "deckcraft-format"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-model",
"serde",
@@ -1000,7 +1000,7 @@ dependencies = [
[[package]]
name = "deckcraft-geom"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"kurbo",
"serde",
@@ -1008,7 +1008,7 @@ dependencies = [
[[package]]
name = "deckcraft-h264"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1017,7 +1017,7 @@ dependencies = [
[[package]]
name = "deckcraft-hevc"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1026,7 +1026,7 @@ dependencies = [
[[package]]
name = "deckcraft-isobmff"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-bitstream",
"thiserror 2.0.21",
@@ -1034,11 +1034,11 @@ dependencies = [
[[package]]
name = "deckcraft-matroska"
version = "0.7.0"
version = "0.8.0"
[[package]]
name = "deckcraft-mcp"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-engine",
"deckcraft-render",
@@ -1048,7 +1048,7 @@ dependencies = [
[[package]]
name = "deckcraft-media"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-av1",
"deckcraft-h264",
@@ -1066,7 +1066,7 @@ dependencies = [
[[package]]
name = "deckcraft-metafile"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"image",
"kurbo",
@@ -1075,7 +1075,7 @@ dependencies = [
[[package]]
name = "deckcraft-model"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-color",
"deckcraft-geom",
@@ -1087,15 +1087,15 @@ dependencies = [
[[package]]
name = "deckcraft-ogg"
version = "0.7.0"
version = "0.8.0"
[[package]]
name = "deckcraft-opus"
version = "0.7.0"
version = "0.8.0"
[[package]]
name = "deckcraft-pdf"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"craft-pdf",
"deckcraft-fonts",
@@ -1113,7 +1113,7 @@ dependencies = [
[[package]]
name = "deckcraft-pptx"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-color",
"deckcraft-engine",
@@ -1129,7 +1129,7 @@ dependencies = [
[[package]]
name = "deckcraft-render"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1148,7 +1148,7 @@ dependencies = [
[[package]]
name = "deckcraft-text"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"chrono",
"deckcraft-color",
@@ -1162,7 +1162,7 @@ dependencies = [
[[package]]
name = "deckcraft-ui-egui"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1184,7 +1184,7 @@ dependencies = [
[[package]]
name = "deckcraft-vp9"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1193,7 +1193,7 @@ dependencies = [
[[package]]
name = "deckcraft-web"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-engine",
"deckcraft-ui-egui",
@@ -5720,7 +5720,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.7.0"
version = "0.8.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.7.0"
version = "0.8.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+3
View File
@@ -131,6 +131,8 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 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-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) |
| 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. |
@@ -226,6 +228,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |
| 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 |
+97 -15
View File
@@ -1,8 +1,11 @@
//! Loopback JSON-lines control server: one request per line, one reply per line.
//! This is the transport the MCP server (`deckcraft-cli mcp --connect`) wraps.
//!
//! Every resource a client can hold is bounded ([`Limits`]): simultaneous connections, request
//! line length, queued requests, and how long a socket may sit idle or blocked on a write.
//! The server only reads requests: a line that is not a JSON object with a string `method` (an
//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is
//! closed, so nothing sent after it runs. Every resource a client can hold is bounded
//! ([`Limits`]): simultaneous connections, request line length, queued requests, and how long a
//! socket may sit idle or blocked on a write.
use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
use std::net::{TcpListener, TcpStream};
@@ -120,6 +123,53 @@ fn read_line(r: &mut impl BufRead, max: usize) -> Line {
}
}
/// One request line, parsed.
enum Parsed {
Request {
id: Value,
method: String,
params: Value,
},
Blank,
/// Not a request: reply with this error and close the connection.
Reject(String),
}
fn parse(bytes: &[u8]) -> Parsed {
let Ok(text) = std::str::from_utf8(bytes) else {
return Parsed::Reject("not a control request (invalid UTF-8); closing the connection".into());
};
let text = text.trim();
if text.is_empty() {
return Parsed::Blank;
}
if is_http_request_line(text) {
return Parsed::Reject("HTTP is not served here: this port takes JSON lines; closing the connection".into());
}
let msg = match serde_json::from_str::<Value>(text) {
Ok(v) => v,
Err(e) => return Parsed::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
};
let Some(obj) = msg.as_object() else {
return Parsed::Reject("not a control request (expected a JSON object); closing the connection".into());
};
let Some(method) = obj.get("method").and_then(Value::as_str) else {
return Parsed::Reject("not a control request (missing string `method`); closing the connection".into());
};
Parsed::Request {
id: obj.get("id").cloned().unwrap_or(Value::Null),
method: method.to_string(),
params: obj.get("params").cloned().unwrap_or(json!({})),
}
}
/// An HTTP/1 request line (`POST / HTTP/1.1`) or the HTTP/2 preface (`PRI * HTTP/2.0`).
fn is_http_request_line(text: &str) -> bool {
const METHODS: [&str; 10] = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"];
text.split_once(' ').is_some_and(|(method, _)| METHODS.contains(&method))
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
}
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits) {
if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
return;
@@ -136,20 +186,14 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
}
Line::Closed => break,
};
let Ok(line) = String::from_utf8(line) else {
if writeln!(out, "{}", json!({"ok": false, "error": "bad JSON: not UTF-8"})).is_err() {
let reply = match parse(&line) {
Parsed::Blank => continue,
Parsed::Reject(e) => {
// The connection closes either way; a peer that can't read the reason loses nothing.
let _ = writeln!(out, "{}", json!({"ok": false, "error": e}));
break;
}
continue;
};
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
Parsed::Request { id, method, params } => {
let (req, rrx) = ControlRequest::new(method, params);
let state = Arc::clone(&req.state);
let mut r = match tx.try_send(req) {
@@ -177,7 +221,6 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
}
r
}
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
};
if let Err(e) = writeln!(out, "{reply}") {
if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) {
@@ -245,6 +288,45 @@ mod tests {
assert_eq!(v["result"], "ui.inspect");
}
/// Every reply line until the server closes the connection.
fn replies_until_closed(r: &mut BufReader<TcpStream>) -> Vec<Value> {
std::iter::from_fn(|| reply(r)).collect()
}
/// A browser can POST to the port: its request line closes the connection before the JSON
/// line in its body is read, so nothing is dispatched.
#[test]
fn an_http_request_closes_the_connection_before_its_body_is_dispatched() {
let (addr, rx) = server(Limits { max_line: 512, ..small() });
let (mut r, mut w) = client(addr);
w.write_all(
b"POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\n\r\n\
{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"slide.new\"}}\n",
)
.unwrap();
let replies = replies_until_closed(&mut r);
assert_eq!(replies.len(), 1, "{replies:?}");
assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}");
assert!(rx.try_recv().is_err(), "the body was dispatched");
}
#[test]
fn a_line_that_is_not_a_request_closes_the_connection() {
let (addr, rx) = server(small());
ui(rx);
for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] {
let (mut r, mut w) = client(addr);
w.write_all(junk).unwrap();
w.write_all(b"{\"id\":2,\"method\":\"m\"}\n").unwrap();
let replies = replies_until_closed(&mut r);
assert_eq!(replies.len(), 1, "{replies:?}");
assert_eq!(replies[0]["ok"], false);
}
assert!(is_http_request_line("OPTIONS * HTTP/1.1"));
assert!(is_http_request_line("PRI * HTTP/2.0"));
assert!(!is_http_request_line("{\"method\":\"GET /\"}"));
}
#[test]
fn oversized_line_closes_connection() {
let (addr, rx) = server(small());
+5
View File
@@ -54,3 +54,8 @@ real_name = "Nikita Rakitin"
[people.RCShoemaker]
real_name = "Ryan C. Shoemaker"
[people.marcoarnulfo]
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
+5 -1
View File
@@ -175,7 +175,11 @@ fn copied_html(s: &Session) -> String {
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
let caret = copying_caret(s);
let t = copy_inner(s, p)?;
let html = if caret { String::new() } else { copied_html(s) };
if caret {
// Nothing is selected, so there is nothing to cut; `delete` would remove the next character.
return Ok(json!({"text": t, "html": ""}));
}
let html = copied_html(s);
delete(s, p)?;
Ok(json!({"text": t, "html": html}))
}
+4 -4
View File
@@ -355,8 +355,8 @@ fn refit(sh: &mut Shape) {
/// The one shape whose points are edited: `id`, or the single selected shape.
fn target(s: &Session, p: &Value, cmd: &str) -> Result<ShapeId> {
let st = s.doc()?;
let id = match p.get("id").and_then(Value::as_u64) {
Some(i) => ShapeId(i as u32),
let id = match id_param(p, "id", cmd)? {
Some(i) => i,
None => match st.selection.shapes.as_slice() {
[one] => *one,
_ => return Err(bad(cmd, "select one shape")),
@@ -429,8 +429,8 @@ pub fn slide_vertices(doc: &deckcraft_model::Presentation, sel: &crate::Selectio
fn points(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id = match p.get("id").and_then(Value::as_u64) {
Some(i) => ShapeId(i as u32),
let id = match id_param(p, "id", "shape.points")? {
Some(i) => i,
None => *st.selection.shapes.first().ok_or_else(|| bad("shape.points", "select a shape"))?,
};
let sh = st.shape(id).ok_or_else(|| bad("shape.points", "no such shape"))?;
+28 -5
View File
@@ -189,8 +189,8 @@ fn tag_style(name: &str, src: &str) -> (i32, i32, i32) {
let mut underline = i32::from(name == "u");
if let Some(style) = attr(src, "style") {
let s = style.to_ascii_lowercase();
if s.contains("font-weight:bold") || s.contains("font-weight: bold") || weight_bold(&s) {
bold = 1;
if let Some(on) = weight_bold(&s) {
bold = i32::from(on);
}
if s.contains("font-style:italic") || s.contains("font-style: italic") {
italic = 1;
@@ -206,10 +206,19 @@ fn tag_style(name: &str, src: &str) -> (i32, i32, i32) {
(bold, italic, underline)
}
fn weight_bold(style: &str) -> bool {
let Some(at) = style.find("font-weight") else { return false };
/// Whether a `font-weight` in `style` turns bold on (`Some(true)`) or off (`Some(false)`).
fn weight_bold(style: &str) -> Option<bool> {
let at = style.find("font-weight")?;
let rest = style[at + "font-weight".len()..].trim_start_matches(|c: char| c == ':' || c.is_whitespace());
rest.starts_with('6') || rest.starts_with('7') || rest.starts_with('8') || rest.starts_with('9')
if rest.starts_with("bold") {
return Some(true);
}
if rest.starts_with("normal") || rest.starts_with("lighter") {
return Some(false);
}
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
let weight: u32 = digits.parse().ok()?;
Some(weight >= 600)
}
fn attr<'a>(tag: &'a str, key: &str) -> Option<&'a str> {
@@ -308,6 +317,20 @@ fn fold(s: &str) -> String {
mod tests {
use super::*;
#[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>";
let body = html_to_body(html).unwrap();
assert_eq!(body.text(), "plain words");
assert_eq!(body.paragraphs[0].runs[0].props.bold, None);
let light = html_to_body("<b style=\"font-weight:300\">thin</b>").unwrap();
assert_eq!(light.paragraphs[0].runs[0].props.bold, None);
let plain = html_to_body("<b>strong</b>").unwrap();
assert_eq!(plain.paragraphs[0].runs[0].props.bold, Some(true));
let heavy = html_to_body("<span style=\"font-weight:700\">heavy</span>").unwrap();
assert_eq!(heavy.paragraphs[0].runs[0].props.bold, Some(true));
}
#[test]
fn bold_italic_and_underline_round_trip() {
let mut body = TextBody::from_text("Hi there");
+5 -7
View File
@@ -1150,19 +1150,17 @@ fn media_options(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({}))
}
fn shape_id(p: &Value, key: &str) -> Option<deckcraft_model::ShapeId> {
p.get(key).and_then(Value::as_u64).map(|v| deckcraft_model::ShapeId(v as u32))
}
fn sites(s: &mut Session, p: &Value) -> Result<Value> {
let id = shape_id(p, "id").ok_or_else(|| bad("shape.sites", "missing `id`"))?;
let id = id_param(p, "id", "shape.sites")?.ok_or_else(|| bad("shape.sites", "missing `id`"))?;
let st = s.doc()?;
let sh = st.shape(id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
Ok(json!(crate::connect::sites(&st.doc, &st.selection, sh).iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
}
fn connect(s: &mut Session, p: &Value) -> Result<Value> {
let (Some(from), Some(to)) = (shape_id(p, "from"), shape_id(p, "to")) else { return Err(bad("shape.connect", "missing `from` / `to`")) };
let (Some(from), Some(to)) = (id_param(p, "from", "shape.connect")?, id_param(p, "to", "shape.connect")?) else {
return Err(bad("shape.connect", "missing `from` / `to`"));
};
let st = s.doc()?;
let site_list = |id| st.shape(id).map(|sh| crate::connect::sites(&st.doc, &st.selection, sh)).unwrap_or_default();
let (a, b) = (site_list(from), site_list(to));
@@ -1186,7 +1184,7 @@ fn connect(s: &mut Session, p: &Value) -> Result<Value> {
}
};
let (p0, p1) = (a[ia], b[ib]);
let id = match shape_id(p, "id") {
let id = match id_param(p, "id", "shape.connect")? {
Some(id) => id,
None => {
let preset = str_param(p, "preset").unwrap_or("straightConnector1");
+20
View File
@@ -218,6 +218,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
&& let Some(sec) = doc.sections.get_mut(si)
{
sec.slides.push(nid);
// Keep the section list in document order so the copy sits next to its source.
let order: Vec<SlideId> = doc.slides.iter().map(|x| x.id).collect();
sec.slides.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
}
}
sel.slide = (last + new_ids.len()).min(doc.slides.len().saturating_sub(1));
@@ -634,6 +637,23 @@ mod tests {
use crate::Session;
#[test]
fn duplicate_keeps_section_order_in_step_with_slides() {
let mut s = Session::with_new();
while s.doc().expect("doc").doc.slides.len() < 3 {
s.execute("slide.new", &json!({})).expect("new slide");
}
s.execute("section.add", &json!({"at": 0})).expect("section");
let orig = s.doc().expect("doc").doc.slides.first().expect("slide").id;
s.execute("slide.duplicate", &json!({"index": 0})).expect("dup");
let st = s.doc().expect("doc");
let all: Vec<_> = st.doc.slides.iter().map(|x| x.id).collect();
let sec = &st.doc.sections.first().expect("section").slides;
assert_eq!(all.len(), 4);
assert_eq!(sec, &all, "section order must match slide order");
assert_eq!(sec.first(), Some(&orig));
}
#[test]
fn new_slide_body_leading_tabs_become_levels() {
let mut s = Session::with_new();
+4 -4
View File
@@ -288,9 +288,9 @@ fn rows_cols(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
with_table(s, p, "table", move |t| {
match op.as_str() {
"rowAbove" => t.insert_row(r),
"rowBelow" => t.insert_row(r + 1),
"rowBelow" => t.insert_row(r.saturating_add(1)),
"colLeft" => t.insert_col(c),
"colRight" => t.insert_col(c + 1),
"colRight" => t.insert_col(c.saturating_add(1)),
"delRow" => {
if !t.delete_row(r) {
return Err(bad("table.deleteRow", "can't delete the last row"));
@@ -537,7 +537,7 @@ fn column_width(s: &mut Session, p: &Value) -> Result<Value> {
with_table(s, p, "table.columnWidth", move |t| {
// Keeping the table's width, the column to the right gives or takes the difference (dragging
// an inner border). The last column has none: the table gets wider or narrower.
let pair = t.cols.get(c).zip(t.cols.get(c + 1)).map(|(a, b)| a.max(0.0) + b.max(0.0)).filter(|x| x.is_finite());
let pair = t.cols.get(c).zip(t.cols.get(c.saturating_add(1))).map(|(a, b)| a.max(0.0) + b.max(0.0)).filter(|x| x.is_finite());
match pair {
Some(pair) if keep_total => {
let min = MIN_COLUMN_WIDTH.min(pair / 2.0);
@@ -545,7 +545,7 @@ fn column_width(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(x) = t.cols.get_mut(c) {
*x = w;
}
if let Some(x) = t.cols.get_mut(c + 1) {
if let Some(x) = t.cols.get_mut(c.saturating_add(1)) {
*x = pair - w;
}
}
+4 -1
View File
@@ -311,7 +311,10 @@ mod tests {
let opened = open(&mut s2, &dir).unwrap();
assert!(s2.documents().iter().all(|d| d.uid != uid), "{opened:?}");
assert!(lock_path(&dir, other).exists(), "a live owner's lock stays");
// Once the owner is gone, its entry is recovered and its lock file cleaned up.
// Once the owner is gone, its entry is recovered and its lock file cleaned up. Unlock
// rather than only drop: a child spawned meanwhile by a parallel test (mkfifo) shares the
// open file and would keep the lock held until it execs.
lock.unlock().unwrap();
drop(lock);
let mut s3 = Session::new();
open(&mut s3, &dir).unwrap();
+66
View File
@@ -94,6 +94,26 @@ fn text_editing_flow() {
assert_eq!(s.doc().unwrap().selection.shapes.len(), 1);
}
/// Cut with only a caret copies nothing, so it must not delete the character after the caret.
#[test]
fn cut_with_only_a_caret_changes_nothing() {
let mut s = session();
let r = s.execute("insert.textBox", &json!({"text": "Hello"})).unwrap();
let id = r["id"].as_u64().unwrap();
s.execute("text.edit", &json!({"id": id})).unwrap();
s.execute("text.move", &json!({"to": "start"})).unwrap();
let out = s.execute("edit.cut", &json!({})).unwrap();
assert_eq!(out["text"], "");
let t = s.execute("text.get", &json!({"id": id})).unwrap();
assert_eq!(t["text"], "Hello", "cut with only a caret changed the text");
// A real selection is still cut.
s.execute("text.move", &json!({"to": "end", "extend": true})).unwrap();
let out = s.execute("edit.cut", &json!({})).unwrap();
assert_eq!(out["text"], "Hello");
let t = s.execute("text.get", &json!({"id": id})).unwrap();
assert_eq!(t["text"], "");
}
#[test]
fn editing_text_refits_a_saved_shrink() {
use deckcraft_model::text::AutoFit;
@@ -3557,3 +3577,49 @@ fn an_exhausted_id_space_refuses_growth() {
sorted.dedup();
assert_eq!(sorted.len(), ids.len(), "no id repeats");
}
#[test]
fn shape_ids_beyond_u32_are_rejected_not_wrapped() {
let (mut s, a, b) = two_shapes();
let wrap = |id: deckcraft_model::ShapeId| u64::from(id.0) + (1u64 << 32);
let shapes_before = s.doc().unwrap().shapes().len();
let r = s.execute("shape.connect", &json!({"from": wrap(a), "to": wrap(b)}));
assert!(r.is_err(), "connect must reject ids beyond u32, got {r:?}");
let r = s.execute("shape.sites", &json!({"id": wrap(a)}));
assert!(r.is_err(), "sites must reject ids beyond u32, got {r:?}");
let r = s.execute("shape.points", &json!({"id": wrap(a)}));
assert!(r.is_err(), "points must reject ids beyond u32, got {r:?}");
let r = s.execute("shape.movePoint", &json!({"id": wrap(a), "index": 0, "x": 1.0, "y": 1.0}));
assert!(r.is_err_and(|e| e.to_string().contains("not a valid shape id")), "movePoint must reject ids beyond u32");
assert_eq!(s.doc().unwrap().shapes().len(), shapes_before, "no connector was created");
// In-range ids still work.
assert!(s.execute("shape.sites", &json!({"id": a.0})).is_ok());
assert!(s.execute("shape.connect", &json!({"from": a.0, "to": b.0})).is_ok());
}
#[test]
fn table_insert_below_or_right_of_a_huge_index_appends_at_the_end() {
let mut s = session();
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]]));
let r = s.execute("table.insertRowBelow", &json!({"id": id.0, "row": u64::MAX}));
assert!(r.is_ok(), "insertRowBelow must clamp a huge row, got {r:?}");
let t = table_of(&s, id);
assert_eq!(t.n_rows(), 3);
assert_eq!(t.cell(0, 0).unwrap().text.text(), "a", "the original rows keep their place");
let r = s.execute("table.insertColumnRight", &json!({"id": id.0, "col": u64::MAX}));
assert!(r.is_ok(), "insertColumnRight must clamp a huge col, got {r:?}");
let t = table_of(&s, id);
assert_eq!(t.n_cols(), 3);
assert_eq!(t.cell(0, 1).unwrap().text.text(), "b", "the original columns keep their place");
}
#[test]
fn table_column_width_of_a_huge_column_is_ignored() {
let mut s = session();
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]]));
for keep_total in [true, false] {
let r = s.execute("table.columnWidth", &json!({"id": id.0, "col": u64::MAX, "width": 50.0, "keepTotal": keep_total}));
assert!(r.is_ok(), "columnWidth must ignore a huge col (keepTotal {keep_total}), got {r:?}");
assert_eq!(table_of(&s, id).cols, vec![100.0, 100.0], "no column changes");
}
}
+28
View File
@@ -59,6 +59,34 @@ fn start(p: &mut Presentation) {
p.slides.clear();
}
#[test]
fn saved_trigger_reveals_then_hides_on_separate_clicks() {
let mut p = defaults::new_presentation(None);
start(&mut p);
let button = shape(&mut p, "Button", "Toggle", 20.0, 20.0, 100.0, 40.0);
let target = shape(&mut p, "Target", "Answer", 200.0, 100.0, 200.0, 80.0);
let animations = vec![
Animation {
shape: target.id,
trigger: Some(button.id),
effect: "appear".into(),
start: AnimStart::OnClick,
duration_ms: 0,
..Default::default()
},
disappear_on_click(button.id, target.id),
];
add_slide(&mut p, vec![button, target], animations, false);
let mut show = Show::open(&p);
assert!(!show.shown("Target"));
for _ in 0..2 {
assert_eq!(show.click("Button"), Click::Animated);
assert!(show.shown("Target"), "first click reveals the answer until the next click");
assert_eq!(show.click("Button"), Click::Animated);
assert!(!show.shown("Target"), "second click hides the answer");
}
}
/// The slide show window's click handling, on a deck read from a PPTX file.
struct Show {
pres: Presentation,
+52 -2
View File
@@ -10,7 +10,7 @@ use std::io::{Cursor, Read, Write};
use std::sync::Arc;
use deckcraft_model::text::{Paragraph, TextBody};
use deckcraft_model::{LayoutType, Presentation, defaults};
use deckcraft_model::{LayoutType, Presentation, ShapeKind, defaults};
pub const MIMETYPE: &str = "application/vnd.storyteller.deckcraft+zip";
/// Files written before the SlideCraft → DeckCraft rename (`.slidecraft`) are still read.
@@ -418,7 +418,12 @@ pub fn outline_to_slides(p: &mut Presentation, text: &str) -> usize {
let tabs = raw.chars().take_while(|c| *c == '\t').count();
let spaces = raw.chars().take_while(|c| *c == ' ').count() / 2;
let level = tabs.max(spaces);
let line = raw.trim().trim_start_matches(['-', '*', '•']).trim().to_string();
let t = raw.trim();
let line = match t.strip_prefix(['-', '*', '•']) {
Some(rest) if rest.starts_with([' ', '\t']) => rest.trim(),
_ => t,
}
.to_string();
if level == 0 {
slides.push((line, Vec::new()));
} else if let Some((_, body)) = slides.last_mut() {
@@ -505,6 +510,13 @@ pub fn slides_to_outline(p: &Presentation) -> String {
out.push('\n');
}
}
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_str(&cells.join("\t"));
out.push('\n');
}
}
}
}
out
@@ -811,6 +823,15 @@ mod tests {
assert!(o.contains("Intro\n\tPoint one\n\t\tSub point\nSecond"), "{o}");
}
#[test]
fn outline_keeps_leading_dash_text() {
let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
let n = outline_to_slides(&mut p, "-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\n- Bullets\n\t- Point\n\t• Other");
assert_eq!(n, 2);
let o = slides_to_outline(&p);
assert!(o.contains("-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\nBullets\n\tPoint\n\tOther"), "{o}");
}
#[test]
fn markdown_headings_and_list_items() {
assert_eq!(md_heading("# Intro"), Some("Intro"));
@@ -830,3 +851,32 @@ mod tests {
}
}
}
/// #814: slides_to_outline includes table cell text, which it used to skip.
#[test]
fn outline_includes_table_text() {
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");
s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title()));
let mut body = Shape { xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 100.0)), ..Default::default() };
body.text = Some(TextBody::from_text("Text box body"));
s.shapes.push(body);
let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() };
for texts in [["Cell A1", "Cell B1"], ["Cell A2", "Cell 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);
assert!(out.contains("Text box body"), "{out}");
assert!(out.contains("Cell A1\tCell B1"), "{out}");
assert!(out.contains("Cell A2\tCell B2"), "{out}");
}
+4 -15
View File
@@ -655,21 +655,10 @@ pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idm
w.close("p:seq");
}
for (trig, effs) in triggered {
// Everything a trigger starts plays from its click.
let effs: Vec<Eff> = effs
.into_iter()
.enumerate()
.map(|(i, e)| Eff {
start: if i == 0 {
AnimStart::OnClick
} else if e.start == AnimStart::OnClick {
AnimStart::AfterPrevious
} else {
e.start
},
..e
})
.collect();
// The first step starts with the button's click. Later On Click effects must remain
// separate steps; With/After Previous effects still belong to their preceding step.
let effs: Vec<Eff> =
effs.into_iter().enumerate().map(|(i, e)| Eff { start: if i == 0 { AnimStart::OnClick } else { e.start }, ..e }).collect();
let clicks = group(effs);
let trig_cond = |w: &mut W| {
w.open("p:cond", A::new().a("evt", "onClick").a("delay", 0));
+95
View File
@@ -0,0 +1,95 @@
//! Synthetic interactive sequences must keep one step per click across a save.
use std::io::{Cursor, Read};
use std::sync::Arc;
use deckcraft_geom::Xfrm;
use deckcraft_model::anim::{AnimStart, Animation, TextBuild};
use deckcraft_model::text::TextBody;
use deckcraft_model::{Presentation, Shape, ShapeId, defaults};
fn deck() -> Presentation {
let mut p = defaults::new_presentation(None);
let shapes: Vec<_> = ["Button", "Other button", "Content"]
.into_iter()
.map(|name| Shape {
id: ShapeId(p.alloc_id().expect("id")),
name: name.into(),
xfrm: Some(Xfrm::new(40.0, 40.0, 200.0, 100.0)),
text: Some(TextBody::from_text("First\nSecond\nThird")),
..Default::default()
})
.collect();
Arc::make_mut(&mut p.slides[0]).shapes = shapes;
p
}
fn round(p: &Presentation) -> Presentation {
deckcraft_pptx::import(&deckcraft_pptx::export(p).expect("export")).expect("import")
}
fn slide_xml(p: &Presentation) -> String {
let bytes = deckcraft_pptx::export(p).expect("export");
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut xml = String::new();
z.by_name("ppt/slides/slide1.xml").expect("slide").read_to_string(&mut xml).expect("xml");
xml
}
#[test]
fn triggered_click_steps_survive_import_edit_save_reopen() {
use AnimStart::{AfterPrevious, OnClick, WithPrevious};
let mut p = deck();
let ids: Vec<_> = p.slides[0].shapes.iter().map(|s| s.id).collect();
let starts = [OnClick, WithPrevious, AfterPrevious, OnClick, WithPrevious, OnClick];
for (i, start) in starts.into_iter().enumerate() {
Arc::make_mut(&mut p.slides[0]).animations.push(Animation {
shape: ids[2],
trigger: Some(ids[0]),
start,
effect: "appear".into(),
delay_ms: i as u32 * 100,
..Default::default()
});
}
// An independent button and the main sequence must not borrow this button's steps.
for trigger in [Some(ids[1]), None] {
for start in [OnClick, AfterPrevious, OnClick] {
Arc::make_mut(&mut p.slides[0]).animations.push(Animation { shape: ids[2], trigger, start, ..Default::default() });
}
}
let mut q = round(&p);
for pass in 0..2 {
for trigger in [Some(ids[0]), Some(ids[1]), None] {
let state = |deck: &Presentation| {
deck.slides[0].animations.iter().filter(|a| a.trigger == trigger).map(|a| (a.start, a.delay_ms)).collect::<Vec<_>>()
};
assert_eq!(state(&q), state(&p), "trigger {trigger:?}, pass {pass}");
}
assert_eq!(q.slides[0].animations.len(), p.slides[0].animations.len());
Arc::make_mut(&mut q.slides[0]).shapes[2].name = "Edited content".into();
q = round(&q);
}
let xml = slide_xml(&p);
assert_eq!(xml.matches("nodeType=\"interactiveSeq\"").count(), 2);
assert_eq!(xml.matches("nodeType=\"clickEffect\"").count(), 7);
// The first step in each interactive sequence begins at zero; subsequent steps wait.
assert_eq!(xml.matches("delay=\"indefinite\"").count(), 5);
}
#[test]
fn triggered_paragraph_build_keeps_a_click_for_each_paragraph() {
let mut p = deck();
let ids: Vec<_> = p.slides[0].shapes.iter().map(|s| s.id).collect();
Arc::make_mut(&mut p.slides[0]).animations =
vec![Animation { shape: ids[2], trigger: Some(ids[0]), text_build: TextBuild::ByParagraph, start: AnimStart::OnClick, ..Default::default() }];
for q in [p.clone(), round(&p)] {
let xml = slide_xml(&q);
assert_eq!(xml.matches("nodeType=\"clickEffect\"").count(), 3);
assert_eq!(xml.matches("delay=\"indefinite\"").count(), 2);
let back = round(&q);
assert_eq!(back.slides[0].animations.len(), 1);
assert_eq!(back.slides[0].animations[0].text_build, TextBuild::ByParagraph);
assert_eq!(back.slides[0].animations[0].trigger, Some(ids[0]));
}
}
+4 -1
View File
@@ -233,7 +233,9 @@ fn notes_menu_language_dialog_and_repeated_words() {
if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_NOTES"), h.render()) {
let _ = img.save(path);
}
// Right-click the notes' first word.
// Right-click the notes' first word, once the (background) notes check is in.
assert_eq!(h.state_mut().squiggles.settle_notes("Remmeber the the plan", "xx-NM"), vec![(0, 8), (13, 16)]);
h.run_steps(2);
let notes = h.query_all_by_role(Role::MultilineTextInput).next().map(|n| n.rect()).unwrap();
right_click(&mut h, egui::pos2(notes.left() + 14.0, notes.top() + 8.0));
if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_NOTES_MENU"), h.render()) {
@@ -245,6 +247,7 @@ fn notes_menu_language_dialog_and_repeated_words() {
let notes_text = |h: &Harness<'_, SlideApp>| h.state().session.active().unwrap().doc.slides[0].notes_text();
assert_eq!(notes_text(&h), "Remember the the plan");
// The repeated "the": its squiggle starts after "Remember the ".
h.state_mut().squiggles.settle_notes("Remember the the plan", "xx-NM");
h.run_steps(4);
let x = h.query_all_by_role(Role::MultilineTextInput).next().map(|n| n.rect()).unwrap();
let mut found = false;
+2 -1
View File
@@ -67,7 +67,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
| Effect Options (direction, sequence by paragraph) | yes | yes |
| Effect dialog: sound, after-animation dim/hide, animate text by word/letter, smooth start/end, bounce end, repeat, rewind | yes | partly; repeat and bounce end round-trip; rewind lost on PPTX save (#77) |
| By-paragraph text builds, sub-bullets follow parents | yes | yes |
| Triggers (on click of a shape) | yes | yes; played in the show: a step per click, the sequence starts over after its last step, a click completes a playing step |
| Triggers (on click of a shape) | yes | yes; played in the show: a step per click, the sequence starts over after its last step, a click completes a playing step. Separate click steps, mixed starts and paragraph builds survive PPTX save (`trigger_steps.rs`) |
| Triggers on a media bookmark | yes | no |
| Animation Painter | yes | yes (#54) |
| Animation Pane: list, reorder, remove | yes | yes |
@@ -94,6 +94,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | trivial | PPTX interactive sequences preserve later On Click effects and one click per paragraph; no change to playback or feature scores |
| 2026-10-14 | trivial | Community PR wave folded: the Animation Pane timeline row says yes (it landed), preview split into canvas (yes) and from the selected effect (no); estimates drop the timeline and transition sounds (~15–27 h) |
| 2026-10-14 | trivial | Repeat and bounce end round-trip through PPTX; rewind is still lost (#77) |
| 2026-10-13 | minor | Animations ▸ Preview and Animation Pane ▸ Play All play the current slide's animations on the editor canvas; preview from the selected effect is still open |
+4
View File
@@ -3,6 +3,10 @@
Start the app with `--control <port>`. The server listens on `127.0.0.1` only and speaks JSON
lines: one request object per line, one reply per line.
A line that is not a JSON object with a string `method` (an HTTP request, text that isn't JSON,
bytes that aren't UTF-8) gets one error reply and the server closes the connection; nothing sent
after it runs. Blank lines are skipped.
The connected MCP client requires the reply's numeric `id` to match its request, `ok` to be a
boolean, and exactly one of `result` (success) or a string `error` (failure). It caps each reply at
16 MiB including its terminating newline, accepts CRLF, and rejects EOF inside a frame. Invalid
+1
View File
@@ -103,6 +103,7 @@ the round trip even though DeckCraft doesn't model it.
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | trivial | PPTX interactive timing sequences keep later On Click steps and paragraph click builds (`trigger_steps.rs`); mixed starts and independent buttons survive import/edit/save/reopen |
| 2026-10-14 | minor | Community PR wave folded: one row each for `.pptx` (seven owned corpus files), outline, 3-D models, embedded fonts, text, effects, 3-D (extrusion sides draw now), SmartArt (written as a diagram) and VBA (tags and custom XML are kept) |
| 2026-10-12 | minor | SmartArt import lays each shape's text out on its `dsp:txXfrm` text rectangle instead of the shape box |
| 2026-10-13 | minor | EMF/WMF rectangle clip regions limit later drawing; EMF+ records remain |
+5 -5
View File
@@ -212,11 +212,10 @@ of the effect gallery. Detail in [animation-parity.md](animation-parity.md). 8
counters): a click on a shape whose action is "play sound" (`ppaction://media`, 126 and 486
shapes in two decks) does nothing; triggers that start on `onStopAudio` or `begin` conditions are
not read as triggers; click boxes are the shape's bounding box (a hexagon's corners count) and do
not follow a shape that a motion path has moved; the PPTX writer turns every "on click" after the
first effect of a trigger into "after previous", so a trigger with several click steps saved from
DeckCraft plays all of them at once.
- **Impact:** games built around sound effects on buttons, or around a button that counts up with
repeated clicks, lose that part when they are saved from DeckCraft or played in it.
not follow a shape that a motion path has moved. Trigger sequences now keep their separate
click steps on PPTX save, including paragraph builds (`crates/pptx/tests/trigger_steps.rs`).
- **Impact:** games built around sound effects on buttons, audio-triggered effects or moving
clickable shapes do not play as authored.
- **Estimate:** 4–8 h.
### Notes and Handout masters
@@ -355,6 +354,7 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | trivial | PPTX trigger sequences keep later On Click steps instead of converting them to After Previous; mixed starts, independent buttons and paragraph builds have synthetic round-trip regressions |
| 2026-10-14 | trivial | Fixed #718: remote MCP no longer accepts unrelated or buffered stale success; loopback regressions cover invalid envelopes, incomplete and oversized frames |
| 2026-10-14 | minor | Community PR wave folded: one entry each for the soak tests (engine and UI), the PPTX corpus (seven owned corpus files), edit points / text warp / 3-D, proofing (Hunspell spelling, thesaurus, AutoCorrect), the smaller missing rows, dialogs (41 vs ~60; Print and Insert Object left) and pen/trackpad input; ranked rows 3, 4 and 10 de-duplicated (comment threads #70 and the pane timeline are done); the fixed command-palette entry (#79) removed |
| 2026-10-13 | minor | EMF/WMF rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ records remain |
+1 -1
View File
@@ -206,7 +206,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
| Animations | Effect options, start (on click/with/after), duration, delay | P0 | D |
| Animations | Animation pane (reorder, timing) | P0 | P |
| Animations | By-paragraph text builds | P1 | D |
| Animations | Triggers | P2 | D |
| Animations | Triggers (separate click steps and paragraph builds round-trip; `crates/pptx/tests/trigger_steps.rs`) | P2 | D |
| Animations | Animation Painter | P2 | D |
| Animations | Preview in editor | P1 | D |
| Slide show | Full-screen show from start / current | P0 | D |
+1
View File
@@ -115,6 +115,7 @@ plan above.
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | trivial | PPTX trigger click steps survive save, including by-paragraph builds. Interactive-deck gaps remain media actions, audio triggers and click-box geometry; milestone scope and feature scores unchanged |
| 2026-10-14 | trivial | M13 reliability: remote MCP validates IDs and envelopes, caps replies and rejects incomplete frames; #718 |
| 2026-10-14 | minor | Community PR wave folded: one Review gate row, one Current focus item 2, one beta row each for items 9 and 10 (timeline and timer controls are in), one after-beta paragraph, one M9 and M10 row, one corpus row in the 2026-10-07 history |
| 2026-10-13 | trivial | Beta item 3: embedded fonts are in, except MicroType Express subsets |