mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 02:57:01 -06:00
Compare commits
26
Commits
@@ -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
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
@@ -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"))?;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user