mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 18:46:57 -06:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09c7f60567 | ||
|
|
155c1cfce2 | ||
|
|
a15d93acdd | ||
|
|
561902d0b3 | ||
|
|
a74191dba4 | ||
|
|
593fd215ff | ||
|
|
1cba8f0f60 | ||
|
|
404d303c2e | ||
|
|
e4fd7a29cc | ||
|
|
0b870df463 | ||
|
|
816f256c5f | ||
|
|
60fa10f99f | ||
|
|
82ef9642a2 | ||
|
|
672d86c0e4 | ||
|
|
54d3f19b5b | ||
|
|
2c57163549 | ||
|
|
0cbe475f13 | ||
|
|
0222a90e5d | ||
|
|
51de1ad2cf | ||
|
|
8b08718b9a | ||
|
|
7cda7eda1d | ||
|
|
57c03d36fc | ||
|
|
40e05abd45 | ||
|
|
b69613bf88 | ||
|
|
a82f691993 | ||
|
|
82ee5dec25 | ||
|
|
2568c55afa | ||
|
|
f4ce764d1c | ||
|
|
16bef64ea1 | ||
|
|
8c3404fc87 | ||
|
|
50d9ad00e6 | ||
|
|
42fb6728fa | ||
|
|
aeaa3684a8 | ||
|
|
c536c6e905 | ||
|
|
7d5dd85209 |
@@ -61,3 +61,17 @@ families excluded by `crates/fonts/build.rs`:
|
||||
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | OFL-1.1 | Japanese and Arabic text |
|
||||
|
||||
Web (wasm32) builds embed only Inter Regular/Bold and BIZ UDPGothic Regular.
|
||||
|
||||
## PDF writer colour profiles
|
||||
|
||||
`craft-pdf` is used with only its `write` feature and default features disabled. Krilla
|
||||
0.8.2 embeds the following profiles; these were already reachable through the direct Krilla
|
||||
exporter. No interpreter fallback fonts or CMaps are enabled, and no profile or font binary
|
||||
is copied into this repository. The licence text is in `licenses/pdf-writer/LICENSE-ICC.txt`.
|
||||
|
||||
| Asset | Author | Source | Licence | SHA-256 | Used for |
|
||||
|---|---|---|---|---|---|
|
||||
| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sGrey-v2-magic.icc | CC0-1.0 | `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481` | PDF export colour profiles |
|
||||
| `krilla 0.8.2 / icc/sGrey-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sGrey-v4.icc | CC0-1.0 | `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d` | PDF export colour profiles |
|
||||
| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v2-magic.icc | CC0-1.0 | `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4` | PDF export colour profiles |
|
||||
| `krilla 0.8.2 / icc/sRGB-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v4.icc | CC0-1.0 | `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353` | PDF export colour profiles |
|
||||
|
||||
Generated
+85
-28
@@ -605,6 +605,12 @@ dependencies = [
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.6.0"
|
||||
@@ -814,6 +820,15 @@ dependencies = [
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -1082,15 +1097,18 @@ version = "0.7.0"
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"deckcraft-fonts",
|
||||
"deckcraft-model",
|
||||
"deckcraft-render",
|
||||
"deckcraft-text",
|
||||
"hayro-syntax",
|
||||
"krilla",
|
||||
"kurbo",
|
||||
"log",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
"wasm-bindgen-test",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1586,12 +1604,6 @@ version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "fearless_simd"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
@@ -2005,7 +2017,6 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9"
|
||||
dependencies = [
|
||||
"fearless_simd 1.1.0",
|
||||
"hayro-ccitt",
|
||||
]
|
||||
|
||||
@@ -2014,9 +2025,6 @@ name = "hayro-jpeg2000"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
|
||||
dependencies = [
|
||||
"fearless_simd 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
@@ -2024,26 +2032,13 @@ version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hayro-jbig2",
|
||||
"hayro-jpeg2000",
|
||||
"memchr",
|
||||
"rustc-hash 2.1.3",
|
||||
"smallvec",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-write"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0b3db92c4917aad24ff30d61413a9a7509ae88b27aee1ae34eff881e1d2723a"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hayro-syntax",
|
||||
"pdf-writer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.3"
|
||||
@@ -2430,15 +2425,13 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bumpalo",
|
||||
"flate2",
|
||||
"float-cmp",
|
||||
"gif",
|
||||
"hayro-write",
|
||||
"image-webp",
|
||||
"imagesize",
|
||||
"indexmap",
|
||||
@@ -2588,6 +2581,16 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minicov"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2698,6 +2701,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
@@ -3095,6 +3107,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.4"
|
||||
@@ -4615,7 +4633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fearless_simd 0.4.1",
|
||||
"fearless_simd",
|
||||
"guillotiere",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -4630,7 +4648,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fearless_simd 0.4.1",
|
||||
"fearless_simd",
|
||||
"guillotiere",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -4758,6 +4776,45 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test"
|
||||
version = "0.3.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"cast",
|
||||
"js-sys",
|
||||
"libm",
|
||||
"minicov",
|
||||
"nu-ansi-term",
|
||||
"num-traits",
|
||||
"oorandom",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-bindgen-test-macro",
|
||||
"wasm-bindgen-test-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-macro"
|
||||
version = "0.3.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-shared"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe"
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.17"
|
||||
|
||||
+5
-2
@@ -7,7 +7,7 @@ default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
version = "0.7.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/deckcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -57,7 +57,7 @@ harfrust = "0.13"
|
||||
vello_cpu = "0.2"
|
||||
vello_common = { version = "0.2", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
krilla = { version = "0.8", features = ["pdf"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
i_overlay = "9"
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
@@ -93,3 +93,6 @@ opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.8.2" }
|
||||
|
||||
@@ -72,3 +72,11 @@ Code dependencies
|
||||
https://github.com/pdeljanov/Symphonia.
|
||||
- The H.264, HEVC, VP9, AV1, Opus, MP4, Matroska and Ogg crates under crates/ are ported from
|
||||
FilmCraft (same owner, MIT OR Apache-2.0).
|
||||
|
||||
- Krilla 0.8.2 (https://github.com/LaurenzV/krilla) is used through craft-pdf under
|
||||
MIT OR Apache-2.0. craft-libs vendors that version with page/surface unwind recovery
|
||||
and rejection of serialization after failed cleanup. Its upstream licence files are
|
||||
retained in craft-libs/licenses/pdf-writer. Its bundled Compact ICC Profiles by Clinton Ingram
|
||||
(https://github.com/saucecontrol/Compact-ICC-Profiles) are CC0-1.0; the profile
|
||||
licence is retained in licenses/pdf-writer/LICENSE-ICC.txt and exact files, sources
|
||||
and hashes are in ATTRIBUTION.md.
|
||||
|
||||
@@ -97,6 +97,9 @@ Today it can:
|
||||
|
||||
## Getting started
|
||||
|
||||
PDF export uses the shared `craft-pdf` writer with import features disabled. See
|
||||
[`crates/pdf/README.md`](crates/pdf/README.md) for the dependency and validation requirements.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/deckcraft
|
||||
cd deckcraft
|
||||
|
||||
@@ -131,6 +131,11 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 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. |
|
||||
| 2026-10-13 | Interface font fallback continues to eligible installed faces when the first covering face is colour or exceeds the 48 MiB limit; document fallback order and the CJK/IME rendering assertions are retained (#101) |
|
||||
| 2026-10-13 | PDFW2: adopt the shared writer with native unwind recovery and explicit font-embedding failures; preserve app-owned export behavior and keep reader dependencies out of production |
|
||||
| 2026-10-13 | Eyedropper: Shape Fill, Shape Outline, Font Color and every other colour menu end with an Eyedropper that takes the colour drawn at a point of the slide (hover shows it, a click applies it, Esc cancels); `slide.colorAt` gives the same from scripts and MCP (#367) |
|
||||
| 2026-10-13 | Slide show: Honeycomb's hexagons cover the whole slide again — the bottom quarter stayed black until the endpoint (#420) |
|
||||
| 2026-10-13 | Copy and Cut from the ribbon, the slide's right-click menu and the menus put the text on the system clipboard, as Ctrl+C and Ctrl+X do (#431) |
|
||||
@@ -221,6 +226,8 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 2026-10-13 | trivial | Progress log: Eyedropper in the colour menus, slide.colorAt (#367) |
|
||||
| 2026-10-13 | trivial | Progress log: video seek frame-loss race and regression coverage |
|
||||
|
||||
@@ -151,10 +151,23 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
let state = Arc::clone(&req.state);
|
||||
let mut r = match tx.try_send(req) {
|
||||
Ok(()) => {
|
||||
ctx.request_repaint();
|
||||
rrx.recv_timeout(limits.reply_timeout).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}))
|
||||
match rrx.recv_timeout(limits.reply_timeout) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
// Give up on the request. While it was still queued this makes
|
||||
// sure it never runs late (#719); if the app had already taken
|
||||
// it, its outcome is unknown rather than a definite failure.
|
||||
if ControlRequest::abandon(&state) {
|
||||
json!({"ok": false, "error": "timeout"})
|
||||
} else {
|
||||
json!({"ok": false, "error": "timeout: the app had already started the request, so it may still run; its outcome is unknown"})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(TrySendError::Full(_)) => json!({"ok": false, "error": "busy: too many queued requests"}),
|
||||
Err(TrySendError::Disconnected(_)) => break,
|
||||
@@ -296,4 +309,43 @@ mod tests {
|
||||
assert_eq!(a["error"], "timeout");
|
||||
drop(rx);
|
||||
}
|
||||
|
||||
/// A request that timed out while it was still queued must not run when the window resumes (#719).
|
||||
#[test]
|
||||
fn timed_out_request_does_not_run_when_the_window_resumes() {
|
||||
let (addr, rx) = server(Limits { max_line: 512, ..small() });
|
||||
let (mut r, mut w) = client(addr);
|
||||
writeln!(w, r#"{{"id":1,"method":"engine.execute","params":{{"command":"slide.new"}}}}"#).unwrap();
|
||||
let t = reply(&mut r).unwrap();
|
||||
assert_eq!(t["error"], "timeout");
|
||||
// The window resumes and drains the queue, exactly as `SlideApp::drain_control` does.
|
||||
let mut app = deckcraft_ui_egui::SlideApp::new(deckcraft_engine::Session::with_new(), deckcraft_ui_egui::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let before = app.session.active().unwrap().doc.slides.len();
|
||||
let req = rx.try_recv().expect("the timed-out request is still queued");
|
||||
if req.take() {
|
||||
let _ = deckcraft_ui_egui::control::handle(&mut app, &ctx, &req);
|
||||
}
|
||||
assert_eq!(app.session.active().unwrap().doc.slides.len(), before, "a request that timed out must not run when the window resumes");
|
||||
drop(rx);
|
||||
}
|
||||
|
||||
/// A request the app had already started when the reply timed out reports an unknown outcome,
|
||||
/// not a definite failure (#719).
|
||||
#[test]
|
||||
fn a_request_already_started_when_the_reply_times_out_reports_an_unknown_outcome() {
|
||||
let (addr, rx) = server(small());
|
||||
let ui = std::thread::spawn(move || {
|
||||
let req = rx.recv().expect("the request is queued");
|
||||
assert!(req.take(), "the app claims the request");
|
||||
// Still running it when the sender's wait expires.
|
||||
std::thread::sleep(Duration::from_millis(450));
|
||||
let _ = req.reply.send(json!({"ok": true, "result": "ran"}));
|
||||
});
|
||||
let (mut r, mut w) = client(addr);
|
||||
writeln!(w, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
let v = reply(&mut r).unwrap();
|
||||
assert!(v["error"].as_str().unwrap().contains("outcome is unknown"), "{v}");
|
||||
ui.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1089,7 +1089,7 @@ fn columns(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
fn autofit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let m = match str_param(p, "mode").unwrap_or("none") {
|
||||
"shrink" => AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 },
|
||||
"shrink" => AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 },
|
||||
"resize" | "shape" => AutoFit::Shape,
|
||||
_ => AutoFit::None,
|
||||
};
|
||||
|
||||
@@ -122,7 +122,7 @@ fn new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sh.text = Some(TextBody::from_text(t));
|
||||
}
|
||||
if let Some(b) = str_param(p, "body")
|
||||
&& let Some(sh) = slide.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| !k.is_title()))
|
||||
&& let Some(sh) = body_placeholder(&mut slide.shapes)
|
||||
{
|
||||
let mut body = TextBody::from_text(b);
|
||||
// Leading tabs set the outline level, as in text.set.
|
||||
@@ -158,6 +158,25 @@ fn new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The placeholder a new slide's `body` goes into: the layout's main content placeholder.
|
||||
/// Prefers `idx="1"` (the content slot PowerPoint fills), then a full-size text placeholder over
|
||||
/// a `half`/`quarter` one, then a text placeholder over a picture/chart/table one; ties keep
|
||||
/// shape order, so a layout with a single body placeholder behaves as before.
|
||||
fn body_placeholder(shapes: &mut [deckcraft_model::Shape]) -> Option<&mut deckcraft_model::Shape> {
|
||||
use deckcraft_model::PhType;
|
||||
let rank = |sh: &deckcraft_model::Shape| {
|
||||
let ph = sh.ph.as_ref()?;
|
||||
if ph.kind.is_title() {
|
||||
return None;
|
||||
}
|
||||
let text = matches!(ph.kind, PhType::Body | PhType::Obj | PhType::SubTitle);
|
||||
let small = matches!(ph.size.as_deref(), Some("half" | "quarter"));
|
||||
Some((ph.idx != 1, small, !text))
|
||||
};
|
||||
let best = shapes.iter().enumerate().filter_map(|(i, sh)| rank(sh).map(|r| (r, i))).min()?.1;
|
||||
shapes.get_mut(best)
|
||||
}
|
||||
|
||||
fn index_of(s: &Session, p: &Value) -> Result<usize> {
|
||||
let st = s.doc()?;
|
||||
let i = usize_param(p, "index").unwrap_or(st.selection.slide);
|
||||
|
||||
@@ -541,7 +541,7 @@ fn shadow(blur: f64, dist: f64) -> Shadow {
|
||||
/// `title` false every line is a bullet (the bullet boxes of the bullet lists).
|
||||
fn text_shape(preset: &str, x: Xfrm, lines: &[&DiagramNode], title: bool) -> Shape {
|
||||
let mut body = TextBody::default();
|
||||
body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 });
|
||||
body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 });
|
||||
let top = lines.first().map(|n| if title { n.level } else { n.level.saturating_sub(1) }).unwrap_or(0);
|
||||
for (k, n) in lines.iter().enumerate() {
|
||||
let mut para = Paragraph::new(&n.text);
|
||||
@@ -1913,7 +1913,7 @@ fn to_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
body.paragraphs.push(Paragraph::new(""));
|
||||
}
|
||||
body.body.wrap = Some(true);
|
||||
body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 });
|
||||
body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 });
|
||||
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad(CMD, NO_ID_SPACE))?);
|
||||
let tb =
|
||||
Shape { id, name: "Text".into(), xfrm: Some(area), text_box: true, text: Some(body), geom: Geom::preset("rect"), ..Default::default() };
|
||||
|
||||
@@ -119,9 +119,14 @@ pub(crate) fn edit_text<T>(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut T
|
||||
let mut t = t0.clone();
|
||||
let body = body_mut(doc, sel, &t).ok_or_else(|| bad("text", "the text being edited is gone"))?;
|
||||
let r = f(body, &mut t);
|
||||
// The saved shrink-on-overflow scale was for the old text: fit the new text afresh.
|
||||
// The saved shrink-on-overflow scale was for the old text: fit the new text afresh. A
|
||||
// body whose file carried no `fontScale` keeps none (100 %) here; `fit_shrink_scale`
|
||||
// below fills in the computed scale when the new text overflows (#724).
|
||||
if let Some(AutoFit::Shrink { font_scale, line_reduction }) = &mut body.body.autofit {
|
||||
(*font_scale, *line_reduction) = (1.0, 0.0);
|
||||
if font_scale.is_some() {
|
||||
*font_scale = Some(1.0);
|
||||
}
|
||||
*line_reduction = 0.0;
|
||||
}
|
||||
sel.text = Some(t);
|
||||
Ok(r)
|
||||
@@ -132,8 +137,11 @@ pub(crate) fn edit_text<T>(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut T
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Grow/shrink an auto-sizing text box (Resize shape to fit text) to its text.
|
||||
/// Refit a shape after its text changed: remember the shrink-on-overflow scale the layout
|
||||
/// computes (#724), then grow/shrink an auto-sizing text box (Resize shape to fit text) to its
|
||||
/// text.
|
||||
pub fn fit_text_box(s: &mut Session, id: ShapeId) -> Result<()> {
|
||||
fit_shrink_scale(s, id)?;
|
||||
let st = s.doc()?;
|
||||
let Some(sh) = st.shape(id).cloned() else { return Ok(()) };
|
||||
let Some(body) = sh.text.clone() else { return Ok(()) };
|
||||
@@ -171,6 +179,53 @@ pub fn fit_text_box(s: &mut Session, id: ShapeId) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Store the shrink-on-overflow scale the layout computes for `id`, so a save keeps
|
||||
/// `fontScale`/`lnSpcReduction` in step with what DeckCraft draws (#724). Runs after a text
|
||||
/// change: a body with no meaningful saved scale — a file's bare `<a:normAutofit/>` (`None`) or
|
||||
/// the reset an edit leaves (`1.0`, no reduction) — is fitted afresh; a scale the file saved is
|
||||
/// left as it is, as PowerPoint draws it.
|
||||
fn fit_shrink_scale(s: &mut Session, id: ShapeId) -> Result<()> {
|
||||
let st = s.doc()?;
|
||||
let Some(sh) = st.shape(id).cloned() else { return Ok(()) };
|
||||
let Some(body) = sh.text.clone() else { return Ok(()) };
|
||||
let Some(AutoFit::Shrink { font_scale, line_reduction }) = body.body.autofit else { return Ok(()) };
|
||||
if !(font_scale.is_none() || (font_scale == Some(1.0) && line_reduction == 0.0)) {
|
||||
return Ok(());
|
||||
}
|
||||
let doc = st.doc.clone();
|
||||
let sel = st.selection.clone();
|
||||
let Some(ctx) = ctx_for(&doc, &sel) else { return Ok(()) };
|
||||
let x = xfrm_of(&doc, &sel, &sh);
|
||||
let tr = deckcraft_render::shape_geometry(&sh, x.w, x.h).text_rect;
|
||||
// Lay out as if the scale had just been reset, so the fit is computed even for `None` (which
|
||||
// is drawn at 100 % and only fitted again when its text changes).
|
||||
let mut probe = body.clone();
|
||||
probe.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 });
|
||||
let opts = deckcraft_text::Opts { rect: tr, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false };
|
||||
let l = deckcraft_text::layout(&ctx, &sh, &probe, &opts);
|
||||
if l.font_scale >= 1.0 && l.line_reduction <= 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
// Change the body in place without an extra undo step (it is part of the edit).
|
||||
let st = s.doc_mut()?;
|
||||
let mut d = (*st.doc).clone();
|
||||
let stored = if let Some(list) = crate::shapes_mut(&mut d, &st.selection)
|
||||
&& let Some(target) = deckcraft_model::find_shape_mut(list, id)
|
||||
&& let Some(t) = target.text.as_mut()
|
||||
&& let Some(AutoFit::Shrink { font_scale, line_reduction }) = &mut t.body.autofit
|
||||
{
|
||||
(*font_scale, *line_reduction) = (Some(l.font_scale), l.line_reduction);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if stored {
|
||||
st.doc = Arc::new(d);
|
||||
st.revision += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn ctx_for<'a>(doc: &'a Presentation, sel: &Selection) -> Option<Ctx<'a>> {
|
||||
match sel.target {
|
||||
Target::Slides => doc.slides.get(sel.slide).and_then(|s| Ctx::for_slide(doc, s)),
|
||||
|
||||
@@ -105,13 +105,45 @@ fn editing_text_refits_a_saved_shrink() {
|
||||
s.edit(|doc, sel| {
|
||||
let shapes = crate::shapes_mut(doc, sel).unwrap();
|
||||
let sh = deckcraft_model::find_shape_mut(shapes, title).unwrap();
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: 0.5, line_reduction: 0.2 });
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.5), line_reduction: 0.2 });
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("text.insert", &json!({"text": "!"})).unwrap();
|
||||
let sh = s.doc().unwrap().shape(title).cloned().unwrap();
|
||||
assert_eq!(sh.text.unwrap().body.autofit, Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }));
|
||||
assert_eq!(sh.text.unwrap().body.autofit, Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }));
|
||||
}
|
||||
|
||||
/// After an edit, the shrink-on-overflow scale the layout computes is stored, so the saved file
|
||||
/// carries the scale DeckCraft draws instead of a bare normAutofit (#724).
|
||||
#[test]
|
||||
fn editing_shrink_text_stores_the_layout_scale() {
|
||||
use deckcraft_model::text::AutoFit;
|
||||
let mut s = session();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 200.0, 40.0]})).unwrap();
|
||||
let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
|
||||
s.execute("format.autofit", &json!({"mode": "shrink"})).unwrap();
|
||||
let text = (0..12).map(|i| format!("Line {i}")).collect::<Vec<_>>().join("\n");
|
||||
s.execute("text.set", &json!({"id": id.0, "text": text})).unwrap();
|
||||
let sh = s.doc().unwrap().shape(id).cloned().unwrap();
|
||||
let Some(AutoFit::Shrink { font_scale, line_reduction }) = sh.text.unwrap().body.autofit else { panic!("shrink") };
|
||||
assert!(font_scale.is_some_and(|s| s < 1.0), "the computed fit is stored: {font_scale:?} / {line_reduction}");
|
||||
// The saved file must carry the scale DeckCraft draws, so PowerPoint opens it the same way.
|
||||
let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("ppt/slides/slide1.xml").unwrap(), &mut xml).unwrap();
|
||||
let scales: Vec<i64> = xml.split("fontScale=\"").skip(1).filter_map(|s| s.split('"').next()?.parse().ok()).collect();
|
||||
assert!(scales.iter().any(|v| *v < 100_000), "the fitted scale is saved: {scales:?}");
|
||||
// Undo restores the state before the edit, and redo brings the stored fit back.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let sh = s.doc().unwrap().shape(id).cloned().unwrap();
|
||||
let Some(AutoFit::Shrink { font_scale, .. }) = sh.text.unwrap().body.autofit else { panic!("shrink") };
|
||||
assert_eq!(font_scale, Some(1.0), "undo restores the scale the edit started from");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
let sh = s.doc().unwrap().shape(id).cloned().unwrap();
|
||||
let Some(AutoFit::Shrink { font_scale, .. }) = sh.text.unwrap().body.autofit else { panic!("shrink") };
|
||||
assert!(font_scale.is_some_and(|v| v < 1.0), "redo brings the stored fit back: {font_scale:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1425,6 +1457,41 @@ fn slide_ranges_parse_like_powerpoints_print_box() {
|
||||
assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped");
|
||||
}
|
||||
|
||||
/// #725: a layout with a small body placeholder ahead of its content placeholder (as Keynote
|
||||
/// exports "Title & Bullets") still gets the body in the content placeholder (`idx="1"`).
|
||||
#[test]
|
||||
fn new_slide_body_goes_to_the_content_placeholder() {
|
||||
for before in [true, false] {
|
||||
let mut s = session();
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let id = deckcraft_model::ShapeId(doc.alloc_id().expect("id"));
|
||||
let lay = std::sync::Arc::make_mut(&mut doc.masters[0])
|
||||
.layouts
|
||||
.iter_mut()
|
||||
.find(|l| l.kind == deckcraft_model::LayoutType::TitleAndContent)
|
||||
.unwrap();
|
||||
let at = lay.shapes.iter().position(|x| x.ph.as_ref().is_some_and(|p| p.idx == 1)).unwrap();
|
||||
let mut small = lay.shapes[at].clone();
|
||||
small.id = id;
|
||||
small.name = "Subtitle 21".into();
|
||||
let ph = small.ph.as_mut().unwrap();
|
||||
ph.kind = deckcraft_model::PhType::Body;
|
||||
ph.idx = 21;
|
||||
ph.size = Some("quarter".into());
|
||||
lay.shapes.insert(if before { at } else { at + 1 }, small);
|
||||
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "Line one\nLine two"})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let slide = &doc.slides[doc.slides.len() - 1];
|
||||
let text = |idx: u32| {
|
||||
let sh = slide.shapes.iter().find(|x| x.ph.as_ref().is_some_and(|p| p.idx == idx)).unwrap();
|
||||
sh.text.as_ref().map(|t| t.text()).unwrap_or_default()
|
||||
};
|
||||
assert_eq!(text(1), "Line one\nLine two", "before = {before}");
|
||||
assert_eq!(text(21), "", "before = {before}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_fill_walks_only_the_cells_a_huge_selection_covers() {
|
||||
// A selection may reach far past the table; filling it touches the real cells and nothing more.
|
||||
|
||||
@@ -188,7 +188,12 @@ impl FontDb {
|
||||
// A letter with marks the run's font lacks (stacked combining marks): a loaded plain
|
||||
// face that draws the whole cluster, as PowerPoint draws the marks, before the run's
|
||||
// font with missing-glyph boxes.
|
||||
_ => self.read_faces().iter().find(|f| !f.is_color() && covers_cluster(f, cluster)).cloned().unwrap_or_else(first),
|
||||
_ => {
|
||||
// Searched before `first` runs: it may load the default family, which needs the
|
||||
// faces' write lock, so the read lock must not be held across it.
|
||||
let found = self.read_faces().iter().find(|f| !f.is_color() && covers_cluster(f, cluster)).cloned();
|
||||
found.unwrap_or_else(first)
|
||||
}
|
||||
};
|
||||
// A pictograph that defaults to text presentation (⚠ ♥ ⬆ ▶) whose first fallback is the
|
||||
// colour emoji font: PowerPoint draws it from a plain symbol font.
|
||||
@@ -277,6 +282,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn falling_back_to_the_default_face_does_not_deadlock() {
|
||||
// An empty chain and a character no loaded face draws: the loaded faces are searched,
|
||||
// then the default family is looked up, which loads it (taking the faces' write lock).
|
||||
// Holding the search's read lock across that lookup hung the thread.
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
let _ = tx.send(db.cascade(&['\u{10FFFD}'], &[], "Regular").family.clone());
|
||||
});
|
||||
let family = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("cascade did not return");
|
||||
assert!(!family.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ligature_carets_never_fail() {
|
||||
let face = FontDb::global().face(crate::DEFAULT_FAMILY, "Regular");
|
||||
|
||||
@@ -1175,17 +1175,40 @@ impl FontDb {
|
||||
None
|
||||
}
|
||||
|
||||
/// A covering fallback accepted by the caller (for example, an interface that cannot use
|
||||
/// colour or very large faces). Rejected faces do not stop the search for another face.
|
||||
/// A policy-specific miss is not stored as a general document-font miss.
|
||||
pub fn fallback_for_filtered(&self, c: char, exclude: u32, accept: impl Fn(&FontFace) -> bool) -> Option<Arc<FontFace>> {
|
||||
if let Some(f) = self.loaded_fallback_filtered(c, exclude, &accept) {
|
||||
return Some(f);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.system_fallback_filtered(c, &|f| f.id != exclude && accept(f), false) {
|
||||
return self.loaded_fallback_filtered(c, exclude, &accept);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn loaded_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
|
||||
self.loaded_fallback_filtered(c, exclude, &|_| true)
|
||||
}
|
||||
|
||||
fn loaded_fallback_filtered(&self, c: char, exclude: u32, accept: &impl Fn(&FontFace) -> bool) -> Option<Arc<FontFace>> {
|
||||
let faces = self.read_faces();
|
||||
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();
|
||||
order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32));
|
||||
order.into_iter().find(|f| f.covers(c)).cloned()
|
||||
order.into_iter().find(|f| f.covers(c) && accept(f)).cloned()
|
||||
}
|
||||
|
||||
/// Load a system font covering `c` (preferred fallback families first, then any cataloged
|
||||
/// file under 40 MB). Returns true if one was loaded. Misses are remembered.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn system_fallback(&self, c: char) -> bool {
|
||||
self.system_fallback_filtered(c, &|_| true, true)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_fallback_filtered(&self, c: char, accept: &impl Fn(&FontFace) -> bool, remember_miss: bool) -> bool {
|
||||
if c.is_control() || c.is_whitespace() {
|
||||
return false;
|
||||
}
|
||||
@@ -1196,7 +1219,7 @@ impl FontDb {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c));
|
||||
let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c) && accept(f));
|
||||
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
|
||||
let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Another thread may have loaded a face for `c` (faces are only ever added), or found
|
||||
@@ -1229,7 +1252,9 @@ impl FontDb {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c);
|
||||
if remember_miss {
|
||||
self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
|
||||
@@ -226,6 +226,35 @@ mod tests {
|
||||
assert!(!shape(&f, "Hello", &[], |c| c).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_fallback_continues_after_a_rejected_loaded_face() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-regular.ttf").to_vec()), 1);
|
||||
let first = db.fallback_for('A', u32::MAX).unwrap();
|
||||
let accepted_family = if first.family == LAST_RESORT_FAMILY { "DeckCraft Legacy" } else { LAST_RESORT_FAMILY };
|
||||
let fallback = db.fallback_for_filtered('A', u32::MAX, |f| f.family == accepted_family).unwrap();
|
||||
assert_eq!(fallback.family, accepted_family);
|
||||
assert!(fallback.covers('A'));
|
||||
assert_ne!(fallback.id(), first.id());
|
||||
assert_eq!(db.fallback_for('A', u32::MAX).unwrap().id(), first.id(), "document fallback order is unchanged");
|
||||
assert!(db.fallback_for_filtered('A', fallback.id(), |f| f.family == accepted_family).is_none(), "the excluded face stays excluded");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn filtered_fallback_loads_an_eligible_system_face_and_does_not_cache_policy_misses() {
|
||||
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
|
||||
let db = FontDb::with_font_dirs(vec![dir]);
|
||||
let first = db.fallback_for('A', u32::MAX).unwrap();
|
||||
assert_ne!(first.family, "DeckCraft Legacy");
|
||||
assert!(db.fallback_for_filtered('A', u32::MAX, |_| false).is_none());
|
||||
assert!(db.fallback_for('A', u32::MAX).is_some(), "an interface policy miss does not hide document glyphs");
|
||||
let accepted = db.fallback_for_filtered('A', u32::MAX, |f| f.family == "DeckCraft Legacy").unwrap();
|
||||
assert_eq!(accepted.family, "DeckCraft Legacy");
|
||||
assert!(accepted.covers('A'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_lookups_all_find_an_installed_family() {
|
||||
// Threads resolving an installed family nobody had loaded yet used to fall back to
|
||||
|
||||
@@ -690,6 +690,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A body whose file had no `fontScale` (`<a:normAutofit/>`) keeps that state through the
|
||||
/// native format: it is drawn at 100 % and only fitted when its text changes (#724).
|
||||
#[test]
|
||||
fn a_bare_normautofit_body_round_trips() {
|
||||
use deckcraft_model::text::{AutoFit, TextBody};
|
||||
let mut p = Presentation::default();
|
||||
let mut body = TextBody::from_text("Overflowing");
|
||||
body.body.autofit = Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 });
|
||||
let sh = deckcraft_model::Shape { text: Some(body), ..Default::default() };
|
||||
Arc::make_mut(&mut p.slides[0]).shapes.push(sh);
|
||||
let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}"));
|
||||
let body = back.slides[0].shapes.last().unwrap().text.clone().unwrap();
|
||||
assert_eq!(body.body.autofit, Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 }));
|
||||
// A scale the file saved round-trips as itself.
|
||||
let mut body = TextBody::from_text("Overflowing");
|
||||
body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.62), line_reduction: 0.2 });
|
||||
let sh = deckcraft_model::Shape { text: Some(body), ..Default::default() };
|
||||
Arc::make_mut(&mut p.slides[0]).shapes.push(sh);
|
||||
let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}"));
|
||||
let body = back.slides[0].shapes.last().unwrap().text.clone().unwrap();
|
||||
assert_eq!(body.body.autofit, Some(AutoFit::Shrink { font_scale: Some(0.62), line_reduction: 0.2 }));
|
||||
}
|
||||
|
||||
/// Only `null`s where numbers belong are patched: other damage still fails.
|
||||
#[test]
|
||||
fn other_damage_is_still_reported() {
|
||||
|
||||
+164
-15
@@ -1,11 +1,15 @@
|
||||
//! Where MCP tool calls end up: a control-channel method call.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
// Includes the newline, matching the control server's request budget. In particular a peer
|
||||
// must not be able to grow a reply indefinitely by withholding its newline.
|
||||
const MAX_REPLY_BYTES: u64 = 16 * 1024 * 1024;
|
||||
|
||||
/// Something that answers control-channel methods (`engine.execute`, `document.inspect`,
|
||||
/// `ui.pointer`, `ui.render`, …). See `deckcraft_ui_egui::control` for the full list.
|
||||
pub trait Backend {
|
||||
@@ -58,7 +62,7 @@ impl Remote {
|
||||
/// Write one request line. A failure here means the app never got a complete line, so retrying is safe.
|
||||
/// A connection the app has already closed (it restarted since the last call) is replaced before writing.
|
||||
fn send(&mut self, line: &str) -> std::io::Result<()> {
|
||||
if self.conn.as_ref().is_none_or(|(_, writer)| is_closed(writer)) {
|
||||
if self.conn.as_ref().is_none_or(|(reader, writer)| !reader.buffer().is_empty() || is_closed(writer)) {
|
||||
self.reconnect()?;
|
||||
}
|
||||
let Some((_, writer)) = self.conn.as_mut() else {
|
||||
@@ -69,15 +73,34 @@ impl Remote {
|
||||
}
|
||||
|
||||
/// Read the reply to a request that was already sent.
|
||||
fn receive(&mut self) -> std::io::Result<String> {
|
||||
fn receive(&mut self, id: u64) -> std::io::Result<Result<Value, String>> {
|
||||
let Some((reader, _)) = self.conn.as_mut() else {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected"));
|
||||
};
|
||||
let mut reply = String::new();
|
||||
if reader.read_line(&mut reply)? == 0 {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed"));
|
||||
let mut reply = Vec::new();
|
||||
reader.take(MAX_REPLY_BYTES + 1).read_until(b'\n', &mut reply)?;
|
||||
if reply.len() as u64 > MAX_REPLY_BYTES {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "control reply exceeds 16 MiB"));
|
||||
}
|
||||
if reply.last() != Some(&b'\n') {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control reply has no terminating newline"));
|
||||
}
|
||||
let invalid = |message| std::io::Error::new(std::io::ErrorKind::InvalidData, message);
|
||||
let v: Value = serde_json::from_slice(&reply).map_err(|e| invalid(format!("bad reply from app: {e}")))?;
|
||||
if v.get("id").and_then(Value::as_u64) != Some(id) {
|
||||
return Err(invalid("control reply has an unexpected request id".to_string()));
|
||||
}
|
||||
match v.get("ok").and_then(Value::as_bool) {
|
||||
Some(true) if v.get("error").is_none() => {
|
||||
v.get("result").cloned().map(Ok).ok_or_else(|| invalid("successful control reply has no result".to_string()))
|
||||
}
|
||||
Some(false) if v.get("result").is_none() => v
|
||||
.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.map(|error| Err(error.to_string()))
|
||||
.ok_or_else(|| invalid("failed control reply has no error string".to_string())),
|
||||
_ => Err(invalid("control reply has an invalid envelope".to_string())),
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,19 +130,13 @@ impl Backend for Remote {
|
||||
format!("DeckCraft app at {} is not reachable: {e}", self.addr)
|
||||
})?;
|
||||
}
|
||||
let reply = self.receive().map_err(|e| {
|
||||
self.receive(id).map_err(|e| {
|
||||
self.conn = None;
|
||||
format!(
|
||||
"lost the reply from the DeckCraft app at {} ({e}); the command may or may not have run, check the document before retrying",
|
||||
self.addr
|
||||
)
|
||||
})?;
|
||||
let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?;
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
} else {
|
||||
Err(v.get("error").and_then(Value::as_str).unwrap_or("unknown error").to_string())
|
||||
}
|
||||
})?
|
||||
}
|
||||
|
||||
fn has_ui(&self) -> bool {
|
||||
@@ -138,6 +155,138 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn reply_once(reply: Vec<u8>) -> (Remote, std::thread::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||
let addr = listener.local_addr().expect("addr").to_string();
|
||||
let peer = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("accept");
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5))).expect("timeout");
|
||||
stream.set_write_timeout(Some(Duration::from_secs(5))).expect("write timeout");
|
||||
let mut line = String::new();
|
||||
BufReader::new(&stream).read_line(&mut line).expect("request");
|
||||
assert_eq!(serde_json::from_str::<Value>(&line).expect("JSON")["id"], 1);
|
||||
// An oversized reply may be rejected before the peer finishes writing it.
|
||||
let _ = stream.write_all(&reply);
|
||||
});
|
||||
(Remote::connect(&addr).expect("connect"), peer)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_reply_id_is_not_command_success() {
|
||||
let (mut remote, peer) = reply_once(b"{\"id\":999,\"ok\":true,\"result\":\"unrelated\"}\n".to_vec());
|
||||
let result = remote.call("engine.execute", json!({"command":"slide.new"}));
|
||||
peer.join().expect("peer");
|
||||
let error = result.expect_err("an unrelated reply cannot confirm a mutation");
|
||||
assert!(error.contains("may or may not have run"), "{error}");
|
||||
assert!(remote.conn.is_none(), "discard the invalid connection without replaying");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_success_and_error_replies_keep_their_meaning() {
|
||||
for (reply, expected) in [
|
||||
("{\"id\":1,\"ok\":true,\"result\":null}\r\n", Ok(Value::Null)),
|
||||
("{\"id\":1,\"ok\":true,\"result\":{\"slides\":2}}\n", Ok(json!({"slides":2}))),
|
||||
("{\"id\":1,\"ok\":false,\"error\":\"disabled\"}\n", Err("disabled".into())),
|
||||
] {
|
||||
let (mut remote, peer) = reply_once(reply.as_bytes().to_vec());
|
||||
assert_eq!(remote.call("engine.execute", json!({})), expected);
|
||||
assert!(remote.conn.is_some(), "valid responses do not invalidate the connection");
|
||||
peer.join().expect("peer");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_reply_envelopes_leave_the_outcome_unknown() {
|
||||
for reply in [
|
||||
"not JSON\n",
|
||||
"[]\n",
|
||||
"{\"ok\":true,\"result\":null}\n",
|
||||
"{\"id\":\"1\",\"ok\":true,\"result\":null}\n",
|
||||
"{\"id\":1,\"ok\":1,\"result\":null}\n",
|
||||
"{\"id\":1,\"ok\":true}\n",
|
||||
"{\"id\":1,\"ok\":false}\n",
|
||||
"{\"id\":1,\"ok\":false,\"error\":42}\n",
|
||||
"{\"id\":1,\"ok\":true,\"result\":null,\"error\":\"failed\"}\n",
|
||||
"{\"id\":1,\"ok\":false,\"result\":null,\"error\":\"failed\"}\n",
|
||||
] {
|
||||
let (mut remote, peer) = reply_once(reply.as_bytes().to_vec());
|
||||
let result = remote.call("engine.execute", json!({}));
|
||||
peer.join().expect("peer");
|
||||
let error = result.expect_err(reply);
|
||||
assert!(error.contains("may or may not have run"), "{reply}: {error}");
|
||||
assert!(remote.conn.is_none(), "{reply}: discard the invalid connection");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eof_without_a_newline_is_not_a_complete_reply() {
|
||||
let (mut remote, peer) = reply_once(b"{\"id\":1,\"ok\":true,\"result\":null}".to_vec());
|
||||
let result = remote.call("engine.execute", json!({}));
|
||||
peer.join().expect("peer");
|
||||
assert!(result.is_err(), "a truncated frame cannot confirm a mutation");
|
||||
assert!(remote.conn.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_reply_is_rejected() {
|
||||
let reply = format!("{{\"id\":1,\"ok\":true,\"result\":\"{}\"}}\n", "x".repeat(16 * 1024 * 1024));
|
||||
let (mut remote, peer) = reply_once(reply.into_bytes());
|
||||
let result = remote.call("engine.execute", json!({}));
|
||||
let invalidated = remote.conn.is_none();
|
||||
// Close the socket before joining: the bounded reader need not drain the peer's reply.
|
||||
remote.conn = None;
|
||||
peer.join().expect("peer");
|
||||
assert!(result.is_err(), "a reply is limited to 16 MiB");
|
||||
let error = result.expect_err("checked above");
|
||||
assert!(error.contains("may or may not have run"), "{error}");
|
||||
assert!(invalidated, "discard the oversized reply's connection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_at_the_size_limit_is_accepted() {
|
||||
let prefix = "{\"id\":1,\"ok\":true,\"result\":\"";
|
||||
let suffix = "\"}\n";
|
||||
let payload_len = 16 * 1024 * 1024 - prefix.len() - suffix.len();
|
||||
let reply = format!("{prefix}{}{suffix}", "x".repeat(payload_len));
|
||||
let (mut remote, peer) = reply_once(reply.into_bytes());
|
||||
let result = remote.call("document.inspect", json!({})).expect("reply at the limit");
|
||||
assert_eq!(result.as_str().map(str::len), Some(payload_len));
|
||||
peer.join().expect("peer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffered_unsolicited_reply_is_discarded_before_sending() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||
let addr = listener.local_addr().expect("addr").to_string();
|
||||
let peer = std::thread::spawn(move || {
|
||||
let (mut first, _) = listener.accept().expect("first connection");
|
||||
first.set_read_timeout(Some(Duration::from_secs(5))).expect("timeout");
|
||||
let mut line = String::new();
|
||||
BufReader::new(&first).read_line(&mut line).expect("initial request");
|
||||
// Even a stale frame whose ID happens to match must not answer a new request.
|
||||
first
|
||||
.write_all(b"{\"id\":1,\"ok\":true,\"result\":\"first\"}\n{\"id\":2,\"ok\":true,\"result\":\"stale\"}\n")
|
||||
.expect("two replies together");
|
||||
line.clear();
|
||||
let sent_on_stale = BufReader::new(&first).read_line(&mut line).expect("first request") != 0;
|
||||
if !sent_on_stale {
|
||||
let (mut fresh, _) = listener.accept().expect("fresh connection");
|
||||
fresh.set_read_timeout(Some(Duration::from_secs(5))).expect("timeout");
|
||||
BufReader::new(&fresh).read_line(&mut line).expect("fresh request");
|
||||
fresh.write_all(b"{\"id\":2,\"ok\":false,\"error\":\"rejected\"}\n").expect("actual reply");
|
||||
}
|
||||
sent_on_stale
|
||||
});
|
||||
let mut remote = Remote::connect(&addr).expect("connect");
|
||||
assert_eq!(remote.call("document.inspect", json!({})), Ok(json!("first")));
|
||||
// Force the stale frame into BufReader, where a socket-only peek cannot see it.
|
||||
assert!(!remote.conn.as_mut().expect("connection").0.fill_buf().expect("buffer").is_empty());
|
||||
let result = remote.call("engine.execute", json!({"command":"slide.new"}));
|
||||
let sent_on_stale = peer.join().expect("peer");
|
||||
assert_eq!(result, Err("rejected".into()), "only the fresh peer's response counts");
|
||||
assert!(!sent_on_stale, "replace a connection with buffered unsolicited bytes before sending");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lost_reply_is_not_resent() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||
|
||||
+26
-3
@@ -92,9 +92,15 @@ impl Container {
|
||||
} else if at(0, &ASF_HEADER) {
|
||||
Container::Asf
|
||||
} else if at(0, b"ID3") {
|
||||
// ID3v2 is followed by MP3 frames (or, rarely, ADTS).
|
||||
let size = b.get(6..10).map(|s| s.iter().fold(0usize, |a, &x| (a << 7) | (x & 0x7f) as usize)).unwrap_or(0);
|
||||
match Container::sniff(b.get(10 + size..).unwrap_or(&[])) {
|
||||
// ID3v2 is followed by MP3 frames (or, rarely, ADTS). The chain of tags is walked
|
||||
// iteratively — a crafted file can carry thousands of empty tags — and only the
|
||||
// bytes after the last one are sniffed.
|
||||
let mut rest = b;
|
||||
while rest.starts_with(b"ID3") {
|
||||
let size = rest.get(6..10).map(|s| s.iter().fold(0usize, |a, &x| (a << 7) | (x & 0x7f) as usize)).unwrap_or(0);
|
||||
rest = rest.get(10 + size..).unwrap_or(&[]);
|
||||
}
|
||||
match Container::sniff(rest) {
|
||||
Container::Adts => Container::Adts,
|
||||
_ => Container::Mp3,
|
||||
}
|
||||
@@ -126,3 +132,20 @@ impl Container {
|
||||
|
||||
/// Worker threads are available (not on wasm32).
|
||||
pub(crate) const THREADS: bool = !cfg!(target_arch = "wasm32");
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn id3_tag_chains_do_not_recurse_the_stack() {
|
||||
// 2 MB of empty ID3v2 tags (10 bytes each) chained ahead of the MP3 frames: walking
|
||||
// the chain must not cost one stack frame per tag.
|
||||
let mut b = Vec::with_capacity(200_000 * 10 + 2);
|
||||
for _ in 0..200_000 {
|
||||
b.extend_from_slice(&[b'I', b'D', b'3', 4, 0, 0, 0, 0, 0, 0]);
|
||||
}
|
||||
b.extend_from_slice(&[0xFF, 0xFB]); // an MP3 frame sync
|
||||
assert_eq!(Container::sniff(&b), Container::Mp3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,13 +203,13 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
|
||||
let title_box = |b: &mut B| {
|
||||
let (x, y, ww, hh) = r(60.0, 29.0, 840.0, 104.0);
|
||||
let mut s = b.shape("Title 1", PhType::Title, 0, x, y, ww, hh);
|
||||
s.text = Some(body_with(Some(Anchor::Middle), Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 })));
|
||||
s.text = Some(body_with(Some(Anchor::Middle), Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 })));
|
||||
s
|
||||
};
|
||||
let body_box = |b: &mut B, name: &str, idx: u32, x: f64, y: f64, ww: f64, hh: f64| {
|
||||
let (x, y, ww, hh) = r(x, y, ww, hh);
|
||||
let mut s = b.shape(name, PhType::Body, idx, x, y, ww, hh);
|
||||
s.text = Some(body_with(Some(Anchor::Top), Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 })));
|
||||
s.text = Some(body_with(Some(Anchor::Top), Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 })));
|
||||
s
|
||||
};
|
||||
|
||||
@@ -263,7 +263,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
|
||||
t.text = Some(TextBody {
|
||||
body: BodyProps {
|
||||
anchor: Some(Anchor::Bottom),
|
||||
autofit: Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }),
|
||||
autofit: Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }),
|
||||
..Default::default()
|
||||
},
|
||||
list_style: ls,
|
||||
@@ -288,7 +288,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
|
||||
st.text = Some(TextBody {
|
||||
body: BodyProps {
|
||||
anchor: Some(Anchor::Top),
|
||||
autofit: Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }),
|
||||
autofit: Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }),
|
||||
..Default::default()
|
||||
},
|
||||
list_style: ls,
|
||||
|
||||
@@ -227,6 +227,29 @@ impl Table {
|
||||
if r1 >= self.rows.len() || c1 >= self.cols.len() || (r0 == r1 && c0 == c1) {
|
||||
return false;
|
||||
}
|
||||
// Grow the rectangle until no existing merge sticks out of it.
|
||||
let (mut r0, mut c0, mut r1, mut c1) = (r0, c0, r1, c1);
|
||||
loop {
|
||||
let before = (r0, c0, r1, c1);
|
||||
for (r, row) in self.rows.iter().enumerate() {
|
||||
for (c, cell) in row.cells.iter().enumerate().take(self.cols.len()) {
|
||||
if cell.is_covered() {
|
||||
continue;
|
||||
}
|
||||
let ar1 = r.saturating_add((cell.row_span.max(1) as usize).saturating_sub(1)).min(self.rows.len() - 1);
|
||||
let ac1 = c.saturating_add((cell.grid_span.max(1) as usize).saturating_sub(1)).min(self.cols.len() - 1);
|
||||
if r <= r1 && ar1 >= r0 && c <= c1 && ac1 >= c0 {
|
||||
r0 = r0.min(r);
|
||||
c0 = c0.min(c);
|
||||
r1 = r1.max(ar1);
|
||||
c1 = c1.max(ac1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (r0, c0, r1, c1) == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut text = vec![];
|
||||
for r in r0..=r1 {
|
||||
for c in c0..=c1 {
|
||||
@@ -505,6 +528,20 @@ mod tests {
|
||||
t.cell(r, c).unwrap().text.text()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merging_over_an_existing_merge_takes_it_in() {
|
||||
let mut t = Table::new(3, 3, 300.0, 30.0);
|
||||
if let Some(c) = t.cell_mut(1, 2) {
|
||||
c.text = TextBody::from_text("X");
|
||||
}
|
||||
assert!(t.merge(1, 1, 1, 2));
|
||||
assert!(t.merge(0, 0, 1, 1));
|
||||
assert_topology(&t);
|
||||
let a = t.cell(0, 0).unwrap();
|
||||
assert_eq!((a.row_span, a.grid_span), (2, 3));
|
||||
assert_eq!(anchor_text(&t, 0, 0), "X");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_and_column_edits_keep_merges_consistent() {
|
||||
for at in 0..=4 {
|
||||
|
||||
@@ -41,8 +41,14 @@ impl Anchor {
|
||||
pub enum AutoFit {
|
||||
/// Do not autofit.
|
||||
None,
|
||||
/// Shrink text on overflow: font scale and line-spacing reduction (0–1).
|
||||
Shrink { font_scale: f64, line_reduction: f64 },
|
||||
/// Shrink text on overflow: font scale and line-spacing reduction (0–1). `font_scale` is
|
||||
/// `None` for a file's `<a:normAutofit/>` with no `fontScale`: the schema default 100 %,
|
||||
/// which PowerPoint draws as it is and only fits again when the text changes (#724).
|
||||
Shrink {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
font_scale: Option<f64>,
|
||||
line_reduction: f64,
|
||||
},
|
||||
/// Resize shape to fit text.
|
||||
Shape,
|
||||
}
|
||||
|
||||
+11
-4
@@ -3,7 +3,7 @@ name = "deckcraft-pdf"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "DeckCraft PDF export (krilla): slides, notes pages and handouts with a selectable text layer, links and bookmarks"
|
||||
description = "DeckCraft PDF export (craft-pdf/Krilla): slides, notes pages and handouts with a selectable text layer, links and bookmarks"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -13,13 +13,20 @@ deckcraft-model = { workspace = true }
|
||||
deckcraft-fonts = { workspace = true }
|
||||
deckcraft-text = { workspace = true }
|
||||
deckcraft-render = { workspace = true }
|
||||
krilla = { workspace = true }
|
||||
craft-pdf = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
hayro-syntax = { version = "=0.7.2", default-features = false, features = ["std"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
||||
# Off the web build, krilla compresses each slide's artwork on a background thread while the next
|
||||
# slide renders (the same bytes, in parallel).
|
||||
# slide renders (the same bytes, in parallel). The root patch selects the same pinned
|
||||
# backend as craft-pdf; this dependency only enables its native compression feature.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
krilla = { workspace = true, features = ["rayon"] }
|
||||
krilla = { version = "=0.8.2", features = ["rayon"] }
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# DeckCraft PDF export
|
||||
|
||||
DeckCraft uses `craft-pdf` for writer construction, metadata, fallible font preparation and
|
||||
final serialization. Slide rasterization, the selectable text overlay, font discovery and
|
||||
shaping, hyperlinks, bookmarks, notes and handout layout remain app-owned. The existing
|
||||
renderer also resolves and rasterizes WMF/EMF pictures before export.
|
||||
|
||||
Only the shared `write` feature is enabled, with default features disabled. Krilla retains
|
||||
its raster-image and simple-text features, including notes/footer shaping. PDF embedding is
|
||||
disabled: DeckCraft does not use it. The production exporter has no Hayro parser, interpreter,
|
||||
image-decoder, fallback-font or CMap dependencies. Tests use `hayro-syntax` 0.7.2 directly,
|
||||
with only `std` enabled, to inspect the generated PDFs.
|
||||
|
||||
The exporter runs inside a native unwind guard. Writer failures return `PdfError::Write`.
|
||||
A font that cannot be prepared for embedding now produces an error instead of silently
|
||||
omitting its selectable text or notes. The shared Krilla 0.8.2 patch avoids a second panic
|
||||
in page/surface cleanup while unwinding and permanently poisons a document after cleanup
|
||||
failure, so a retained writer cannot serialize it. Successful serialization paths are
|
||||
unchanged. Native unwind guards cannot catch WASM aborts or process-wide allocation failures.
|
||||
|
||||
## Building and validation
|
||||
|
||||
Rust 1.95 or newer is required. The workspace pins `craft-pdf` 0.1.0 and its Krilla 0.8.2
|
||||
patch to `https://github.com/storytold/craft-libs` revision
|
||||
`39adbdf756569c8e03adb747d5e063f468ca98c1`. Both packages resolve from that immutable Git source;
|
||||
a sibling checkout is unnecessary. Publish and review this shared-library prerequisite
|
||||
before publishing the app migration, so fresh CI and release builds can fetch it.
|
||||
|
||||
Run `cargo test -p deckcraft-pdf` for export contracts and `cargo xtask ci` for all workspace
|
||||
gates. PDF contract tests also run with `wasm-bindgen-test-runner` on
|
||||
`wasm32-unknown-unknown`. The `pdf-deps` gate verifies the two writer source pins,
|
||||
retained Krilla image/text features and the development-only parser boundary.
|
||||
|
||||
This migration does not add vector slide artwork or change text-shaping/ToUnicode policy.
|
||||
+54
-30
@@ -8,8 +8,13 @@
|
||||
//! Layouts: one slide per page, notes pages (slide above its speaker notes) and handouts (1, 2, 3,
|
||||
//! 4, 6 or 9 slides per page).
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use craft_pdf::write::{self, Metadata, Writer, backend as krilla};
|
||||
|
||||
use deckcraft_fonts::FontDb;
|
||||
use deckcraft_model::Presentation;
|
||||
use deckcraft_model::text::Action as ClickAction;
|
||||
@@ -19,7 +24,6 @@ use krilla::annotation::{Annotation, LinkAnnotation, Target};
|
||||
use krilla::destination::XyzDestination;
|
||||
use krilla::geom::{Point, Size, Transform};
|
||||
use krilla::image::Image;
|
||||
use krilla::metadata::Metadata;
|
||||
use krilla::num::NormalizedF32;
|
||||
use krilla::outline::{Outline, OutlineNode};
|
||||
use krilla::page::PageSettings;
|
||||
@@ -37,6 +41,12 @@ pub enum PdfError {
|
||||
Write(String),
|
||||
}
|
||||
|
||||
impl From<write::WriteError> for PdfError {
|
||||
fn from(error: write::WriteError) -> Self {
|
||||
Self::Write(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, PdfError>;
|
||||
|
||||
/// What each page shows.
|
||||
@@ -118,6 +128,10 @@ pub fn notes_slide_box(paper: (f64, f64), aspect: f64) -> Rect {
|
||||
|
||||
/// Export `pres` as PDF bytes.
|
||||
pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
write::guard(|| export_inner(pres, opts))
|
||||
}
|
||||
|
||||
fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
let slides: Vec<usize> = match &opts.slides {
|
||||
Some(v) => v.iter().copied().filter(|i| *i < pres.slides.len()).collect(),
|
||||
None => (0..pres.slides.len()).filter(|i| opts.include_hidden || !pres.slides[*i].hidden).collect(),
|
||||
@@ -125,16 +139,16 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
if slides.is_empty() {
|
||||
return Err(PdfError::NoPages);
|
||||
}
|
||||
let mut pdf = krilla::Document::new();
|
||||
let title = opts.title.clone().unwrap_or_else(|| pres.props.title.clone());
|
||||
let mut meta = Metadata::new().creator("DeckCraft".into()).producer("DeckCraft".into());
|
||||
if !title.trim().is_empty() {
|
||||
meta = meta.title(title);
|
||||
}
|
||||
if !pres.props.author.trim().is_empty() {
|
||||
meta = meta.authors(vec![pres.props.author.clone()]);
|
||||
}
|
||||
pdf.set_metadata(meta);
|
||||
let metadata = Metadata {
|
||||
creator: "DeckCraft".into(),
|
||||
producer: "DeckCraft".into(),
|
||||
title: (!title.trim().is_empty()).then_some(title),
|
||||
authors: if pres.props.author.trim().is_empty() { Vec::new() } else { vec![pres.props.author.clone()] },
|
||||
..Default::default()
|
||||
};
|
||||
let mut writer = Writer::new(Default::default(), metadata);
|
||||
let pdf = writer.document_mut();
|
||||
|
||||
let (sw, sh) = (pres.slide_size.width.max(1.0), pres.slide_size.height.max(1.0));
|
||||
let mut ex = Exporter { pres, opts, fonts: HashMap::new() };
|
||||
@@ -174,16 +188,16 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
{
|
||||
let mut s = page.surface();
|
||||
for (i, b) in v {
|
||||
ex.slide(&mut s, *i, *b);
|
||||
ex.slide(&mut s, *i, *b)?;
|
||||
annots.extend(ex.links(*i, *b, &page_of));
|
||||
}
|
||||
match opts.layout {
|
||||
PageLayout::Notes => {
|
||||
if let Some((i, _)) = v.first() {
|
||||
let notes = pres.slides[*i].notes_text();
|
||||
ex.paragraphs(&mut s, ¬es, notes_frames(pres, opts.paper).1, 12.0);
|
||||
ex.paragraphs(&mut s, ¬es, notes_frames(pres, opts.paper).1, 12.0)?;
|
||||
}
|
||||
ex.page_texts(&mut s, pres.notes_master.as_deref(), pw, ph, v.first().map(|x| x.0 + 1).unwrap_or(1));
|
||||
ex.page_texts(&mut s, pres.notes_master.as_deref(), pw, ph, v.first().map(|x| x.0 + 1).unwrap_or(1))?;
|
||||
}
|
||||
PageLayout::Handouts { per_page } => {
|
||||
if per_page == 3 {
|
||||
@@ -191,7 +205,7 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
ex.note_lines(&mut s, Rect::new(b.x1 + 24.0, b.y0, pw - 54.0, b.y1));
|
||||
}
|
||||
}
|
||||
ex.page_texts(&mut s, pres.handout_master.as_deref(), pw, ph, page_of.get(&v[0].0).map(|p| p + 1).unwrap_or(1));
|
||||
ex.page_texts(&mut s, pres.handout_master.as_deref(), pw, ph, page_of.get(&v[0].0).map(|p| p + 1).unwrap_or(1))?;
|
||||
}
|
||||
PageLayout::Slides => {}
|
||||
}
|
||||
@@ -202,7 +216,7 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
}
|
||||
page.finish();
|
||||
}
|
||||
pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))
|
||||
writer.finish().map_err(Into::into)
|
||||
}
|
||||
|
||||
/// A notes-size master's rect of placeholder `kind`, scaled to a page of `paper` points.
|
||||
@@ -368,7 +382,7 @@ pub fn handout_boxes(per_page: u8, paper: (f64, f64), aspect: f64) -> Vec<Rect>
|
||||
struct Exporter<'a> {
|
||||
pres: &'a Presentation,
|
||||
opts: &'a PdfOptions,
|
||||
fonts: HashMap<u32, Option<Font>>,
|
||||
fonts: HashMap<u32, Font>,
|
||||
}
|
||||
|
||||
fn tf(a: Affine) -> Transform {
|
||||
@@ -385,12 +399,17 @@ fn grey(v: u8, opacity: f32) -> Fill {
|
||||
}
|
||||
|
||||
impl Exporter<'_> {
|
||||
fn font(&mut self, face: &deckcraft_fonts::FontFace) -> Option<Font> {
|
||||
self.fonts.entry(face.id()).or_insert_with(|| Font::new(face.data().to_vec().into(), face.index())).clone()
|
||||
fn font(&mut self, face: &deckcraft_fonts::FontFace) -> Result<Font> {
|
||||
if let Some(font) = self.fonts.get(&face.id()) {
|
||||
return Ok(font.clone());
|
||||
}
|
||||
let font = write::font(face.data(), face.index(), None)?;
|
||||
self.fonts.insert(face.id(), font.clone());
|
||||
Ok(font)
|
||||
}
|
||||
|
||||
/// Slide `i` drawn into `b` (page points).
|
||||
fn slide(&mut self, s: &mut Surface, i: usize, b: Rect) {
|
||||
fn slide(&mut self, s: &mut Surface, i: usize, b: Rect) -> Result<()> {
|
||||
let pres = self.pres;
|
||||
let (sw, sh) = (pres.slide_size.width.max(1.0), pres.slide_size.height.max(1.0));
|
||||
let k = b.width() / sw;
|
||||
@@ -432,14 +451,15 @@ impl Exporter<'_> {
|
||||
if self.opts.text_layer {
|
||||
let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k);
|
||||
for pt in deckcraft_render::place_slide(pres, i).texts {
|
||||
self.text_layer(s, &pt, view, false);
|
||||
self.text_layer(s, &pt, view, false)?;
|
||||
}
|
||||
let _ = sh;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Invisible glyphs exactly over the rasterised text.
|
||||
fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) {
|
||||
fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) -> Result<()> {
|
||||
for run in &pt.layout.runs {
|
||||
if run.glyphs.is_empty() || run.size <= 0.0 {
|
||||
continue;
|
||||
@@ -457,7 +477,7 @@ impl Exporter<'_> {
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(font) = self.font(&run.face) else { continue };
|
||||
let font = self.font(&run.face)?;
|
||||
let n = run.glyphs.len();
|
||||
let ranges = glyph_text_ranges(&text, run.chars, &run.cells, n);
|
||||
let (x0, y0) = (run.glyphs[0].1, run.glyphs[0].2);
|
||||
@@ -486,6 +506,7 @@ impl Exporter<'_> {
|
||||
s.set_fill(None);
|
||||
s.pop();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn links(&self, i: usize, b: Rect, page_of: &HashMap<usize, usize>) -> Vec<Annotation> {
|
||||
@@ -516,15 +537,15 @@ impl Exporter<'_> {
|
||||
out
|
||||
}
|
||||
|
||||
fn ui_font(&mut self) -> Option<(Font, std::sync::Arc<deckcraft_fonts::FontFace>)> {
|
||||
fn ui_font(&mut self) -> Result<(Font, std::sync::Arc<deckcraft_fonts::FontFace>)> {
|
||||
let face = FontDb::global().face(deckcraft_fonts::DEFAULT_FAMILY, "Regular");
|
||||
let f = self.font(&face)?;
|
||||
Some((f, face))
|
||||
Ok((f, face))
|
||||
}
|
||||
|
||||
/// Wrapped plain text (notes).
|
||||
fn paragraphs(&mut self, s: &mut Surface, text: &str, area: Rect, size: f64) {
|
||||
let Some((font, face)) = self.ui_font() else { return };
|
||||
fn paragraphs(&mut self, s: &mut Surface, text: &str, area: Rect, size: f64) -> Result<()> {
|
||||
let (font, face) = self.ui_font()?;
|
||||
let k = size / face.upem.max(1.0);
|
||||
let width = |w: &str| w.chars().map(|c| face.advance(face.glyph_for(c)) * k).sum::<f64>();
|
||||
let lh = size * 1.3;
|
||||
@@ -554,6 +575,7 @@ impl Exporter<'_> {
|
||||
y += lh;
|
||||
}
|
||||
s.set_fill(None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ruled lines for handwritten notes (3-per-page handouts).
|
||||
@@ -578,21 +600,23 @@ impl Exporter<'_> {
|
||||
|
||||
/// A notes or handout page's header, date, footer and page number from its master; just the
|
||||
/// page number when the deck has no such master.
|
||||
fn page_texts(&mut self, s: &mut Surface, master: Option<&deckcraft_model::Master>, pw: f64, ph: f64, n: usize) {
|
||||
fn page_texts(&mut self, s: &mut Surface, master: Option<&deckcraft_model::Master>, pw: f64, ph: f64, n: usize) -> Result<()> {
|
||||
let Some(m) = master else { return self.footer(s, pw, ph, n) };
|
||||
// Laid out by the text engine in the placeholders, scaled from the notes size to the page:
|
||||
// wrapping, alignment and mixed formatting as the master has them.
|
||||
let pres = self.pres;
|
||||
let view = Affine::scale_non_uniform(pw / pres.notes_size.width.max(1.0), ph / pres.notes_size.height.max(1.0));
|
||||
for pt in deckcraft_render::place_master_texts(pres, m, n) {
|
||||
self.text_layer(s, &pt, view, true);
|
||||
self.text_layer(s, &pt, view, true)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn footer(&mut self, s: &mut Surface, pw: f64, ph: f64, n: usize) {
|
||||
let Some((font, _)) = self.ui_font() else { return };
|
||||
fn footer(&mut self, s: &mut Surface, pw: f64, ph: f64, n: usize) -> Result<()> {
|
||||
let (font, _) = self.ui_font()?;
|
||||
s.set_fill(Some(grey(0x60, 1.0)));
|
||||
s.draw_text(Point::from_xy((pw - 72.0) as f32, (ph - 30.0) as f32), font, 9.0, &n.to_string(), false, TextDirection::Auto);
|
||||
s.set_fill(None);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+216
-5
@@ -24,33 +24,49 @@ fn opts(layout: PageLayout) -> PdfOptions {
|
||||
PdfOptions { layout, dpi: 48.0, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn exports_slides_notes_and_handouts() {
|
||||
let p = deck(5);
|
||||
for layout in [PageLayout::Slides, PageLayout::Notes, PageLayout::Handouts { per_page: 3 }, PageLayout::Handouts { per_page: 6 }] {
|
||||
let bytes = export(&p, &opts(layout)).expect("export");
|
||||
assert!(bytes.starts_with(b"%PDF-"), "{layout:?}");
|
||||
assert!(bytes.len() > 1000);
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
let (pages, size) = match layout {
|
||||
PageLayout::Slides => (5, (960.0, 540.0)),
|
||||
PageLayout::Notes => (5, (612.0, 792.0)),
|
||||
PageLayout::Handouts { per_page: 3 } => (2, (612.0, 792.0)),
|
||||
_ => (1, (612.0, 792.0)),
|
||||
};
|
||||
assert_eq!(pdf.pages().len(), pages);
|
||||
assert!(pdf.pages().iter().all(|page| page.render_dimensions() == size));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn empty_range_is_an_error() {
|
||||
let p = deck(1);
|
||||
let o = PdfOptions { slides: Some(vec![7]), ..opts(PageLayout::Slides) };
|
||||
assert!(matches!(export(&p, &o), Err(PdfError::NoPages)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn hidden_slides_skipped_unless_asked() {
|
||||
let mut p = deck(2);
|
||||
Arc::make_mut(&mut p.slides[1]).hidden = true;
|
||||
let a = export(&p, &opts(PageLayout::Slides)).unwrap();
|
||||
let b = export(&p, &PdfOptions { include_hidden: true, ..opts(PageLayout::Slides) }).unwrap();
|
||||
assert!(b.len() > a.len());
|
||||
assert_eq!(hayro_syntax::Pdf::new(a.clone()).unwrap().pages().len(), 1);
|
||||
assert_eq!(hayro_syntax::Pdf::new(b.clone()).unwrap().pages().len(), 2);
|
||||
let ranged = export(&p, &PdfOptions { slides: Some(vec![1, 0, 9]), ..opts(PageLayout::Slides) }).unwrap();
|
||||
assert_eq!(hayro_syntax::Pdf::new(ranged.clone()).unwrap().pages().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn handout_boxes_fit_the_paper() {
|
||||
for n in [1, 2, 3, 4, 6, 9] {
|
||||
let v = handout_boxes(n, (612.0, 792.0), 16.0 / 9.0);
|
||||
@@ -67,6 +83,201 @@ fn handout_boxes_fit_the_paper() {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn metadata_retains_values_and_omits_blank_fields() {
|
||||
let mut p = deck(1);
|
||||
p.props.title = " Deck title ".into();
|
||||
p.props.author = " Author ".into();
|
||||
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
assert_eq!(pdf.metadata().title.as_deref(), Some(b" Deck title ".as_slice()));
|
||||
assert_eq!(pdf.metadata().author.as_deref(), Some(b" Author ".as_slice()));
|
||||
assert_eq!(pdf.metadata().creator.as_deref(), Some(b"DeckCraft".as_slice()));
|
||||
assert_eq!(pdf.metadata().producer.as_deref(), Some(b"DeckCraft".as_slice()));
|
||||
assert!(pdf.metadata().creation_date.is_none());
|
||||
p.props.author = " ".into();
|
||||
let bytes = export(&p, &PdfOptions { title: Some(" ".into()), ..opts(PageLayout::Slides) }).unwrap();
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
assert!(pdf.metadata().title.is_none());
|
||||
assert!(pdf.metadata().author.is_none());
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn text_layer_links_and_bookmarks_survive_serialization() {
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
use hayro_syntax::object::{Array, Dict};
|
||||
let mut p = deck(2);
|
||||
let last = p.slides[1].id;
|
||||
let slide = Arc::make_mut(&mut p.slides[0]);
|
||||
for (shape, action) in slide.shapes.iter_mut().zip([Action::Url { url: "https://example.com/deck".into() }, Action::Slide { slide: last }]) {
|
||||
shape.click = Some(Hyperlink { action, tooltip: "Link".into(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None });
|
||||
}
|
||||
for text_layer in [true, false] {
|
||||
let bytes = export(&p, &PdfOptions { text_layer, ..opts(PageLayout::Slides) }).unwrap();
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
let page = &pdf.pages()[0];
|
||||
let annotations = page.raw().get::<Array<'_>>(b"Annots").unwrap();
|
||||
assert_eq!(annotations.iter::<Dict<'_>>().count(), 2);
|
||||
let resources = page.raw().get::<Dict<'_>>(b"Resources").unwrap();
|
||||
let fonts = resources.get::<Dict<'_>>(b"Font");
|
||||
assert_eq!(fonts.is_some_and(|fonts| !fonts.is_empty()), text_layer);
|
||||
let catalog = pdf.xref().get::<Dict<'_>>(pdf.xref().root_id()).unwrap();
|
||||
let outlines = catalog.get::<Dict<'_>>(b"Outlines").unwrap();
|
||||
let first = outlines.get::<Dict<'_>>(b"First").unwrap();
|
||||
assert!(first.get::<Dict<'_>>(b"Next").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a small damaged derivative of our openly licensed last-resort font entirely in
|
||||
/// memory. Its naming/shaping tables still parse, but its missing head table prevents PDF
|
||||
/// embedding. No font bytes or vendor assets are stored as test fixtures.
|
||||
fn font_without_head() -> Vec<u8> {
|
||||
let mut bytes = FontDb::global().face("Ubuntu", "Regular").data().to_vec();
|
||||
for (old, new) in [(b"Ubuntu".as_slice(), b"BadPDF".as_slice()), (b"\0U\0b\0u\0n\0t\0u".as_slice(), b"\0B\0a\0d\0P\0D\0F".as_slice())] {
|
||||
let mut offset = 0;
|
||||
while offset + old.len() <= bytes.len() {
|
||||
if bytes[offset..offset + old.len()] == *old {
|
||||
bytes[offset..offset + old.len()].copy_from_slice(new);
|
||||
offset += old.len();
|
||||
} else {
|
||||
offset += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let tables = u16::from_be_bytes([bytes[4], bytes[5]]) as usize;
|
||||
for i in 0..tables {
|
||||
let offset = 12 + i * 16;
|
||||
if &bytes[offset..offset + 4] == b"head" {
|
||||
bytes[offset..offset + 4].copy_from_slice(b"zzzz");
|
||||
}
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn unembeddable_font_returns_error_instead_of_a_pdf_missing_text() {
|
||||
use deckcraft_model::{Shape, ShapeId, Xfrm};
|
||||
let db = FontDb::global();
|
||||
assert_eq!(db.add_font(font_without_head()), 1);
|
||||
assert_eq!(db.face("BadPDF", "Regular").family, "BadPDF");
|
||||
let mut p = deck(1);
|
||||
let slide = Arc::make_mut(&mut p.slides[0]);
|
||||
let mut body = TextBody::from_text("Text that must not disappear silently");
|
||||
for paragraph in &mut body.paragraphs {
|
||||
for run in &mut paragraph.runs {
|
||||
run.props.font = Some("BadPDF".into());
|
||||
run.props.size = Some(12.0);
|
||||
}
|
||||
}
|
||||
slide.shapes = vec![Shape { id: ShapeId(9000), xfrm: Some(Xfrm::new(20.0, 20.0, 500.0, 200.0)), text: Some(body), ..Default::default() }];
|
||||
let error = export(&p, &opts(PageLayout::Slides)).unwrap_err();
|
||||
assert!(matches!(error, PdfError::Write(_)));
|
||||
assert!(error.to_string().contains("font could not be read for embedding"));
|
||||
// Master text remains visible even when the optional slide text layer is off.
|
||||
// It must propagate the same embedding error through notes and handout pages.
|
||||
let mut header = ph(deckcraft_model::PhType::Header, 0.0, 0.0, 500.0, 40.0, "");
|
||||
header.text = p.slides[0].shapes[0].text.clone();
|
||||
for layout in [PageLayout::Notes, PageLayout::Handouts { per_page: 3 }] {
|
||||
let mut p = deck(1);
|
||||
let master = Some(Arc::new(deckcraft_model::Master { shapes: vec![header.clone()], ..Default::default() }));
|
||||
if layout == PageLayout::Notes {
|
||||
p.notes_master = master;
|
||||
} else {
|
||||
p.handout_master = master;
|
||||
}
|
||||
let error = export(&p, &PdfOptions { text_layer: false, ..opts(layout) }).unwrap_err();
|
||||
assert!(matches!(error, PdfError::Write(_)));
|
||||
assert!(error.to_string().contains("font could not be read for embedding"), "{layout:?}: {error}");
|
||||
}
|
||||
// The failed export leaves no poisoned writer state behind.
|
||||
assert!(export(&deck(1), &opts(PageLayout::Slides)).is_ok());
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
|
||||
use deckcraft_model::{Shape, ShapeId, Xfrm};
|
||||
use hayro_syntax::content::ops::TypedInstruction;
|
||||
use hayro_syntax::object::{Dict, Object, Stream};
|
||||
|
||||
let text = "Café Ελληνικά Привет";
|
||||
let face = FontDb::global().face("Ubuntu", "Regular");
|
||||
assert_eq!(face.family, "Ubuntu");
|
||||
assert!(text.chars().all(|c| face.glyph_for(c) != 0), "missing: {:?}", text.chars().filter(|c| face.glyph_for(*c) == 0).collect::<Vec<_>>());
|
||||
let mut body = TextBody::from_text(text);
|
||||
for paragraph in &mut body.paragraphs {
|
||||
for run in &mut paragraph.runs {
|
||||
run.props.font = Some("Ubuntu".into());
|
||||
run.props.size = Some(20.0);
|
||||
}
|
||||
}
|
||||
let mut p = deck(1);
|
||||
Arc::make_mut(&mut p.slides[0]).shapes =
|
||||
vec![Shape { id: ShapeId(9001), xfrm: Some(Xfrm::new(20.0, 20.0, 920.0, 100.0)), text: Some(body), ..Default::default() }];
|
||||
let placed = deckcraft_render::place_slide(&p, 0);
|
||||
let runs: Vec<_> = placed.texts.iter().flat_map(|placed| &placed.layout.runs).collect();
|
||||
assert!(!runs.is_empty());
|
||||
assert!(runs.iter().all(|run| run.face.family == "Ubuntu" && !run.glyphs.is_empty() && run.glyphs.iter().all(|glyph| glyph.0 != 0)));
|
||||
|
||||
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
let page = &pdf.pages()[0];
|
||||
let resources = page.raw().get::<Dict<'_>>(b"Resources").unwrap();
|
||||
let fonts = resources.get::<Dict<'_>>(b"Font").unwrap();
|
||||
assert_eq!(fonts.len(), 1);
|
||||
let font = fonts.get::<Dict<'_>>(fonts.keys().next().unwrap()).unwrap();
|
||||
let cmap = font.get::<Stream<'_>>(b"ToUnicode").unwrap().decoded().unwrap().into_owned();
|
||||
let cmap = std::str::from_utf8(&cmap).unwrap();
|
||||
// This fixture uses the writer's two-byte CIDs and bfchar mappings. Decode the
|
||||
// content's text-showing operators through that map, not document metadata.
|
||||
let mut mapping = HashMap::<u16, String>::new();
|
||||
let mut in_char_map = false;
|
||||
for line in cmap.lines() {
|
||||
if line.ends_with("beginbfchar") {
|
||||
in_char_map = true;
|
||||
} else if line == "endbfchar" {
|
||||
in_char_map = false;
|
||||
} else if in_char_map {
|
||||
let parts: Vec<_> = line.split_whitespace().collect();
|
||||
let cid = u16::from_str_radix(parts[0].trim_matches(['<', '>']), 16).unwrap();
|
||||
let value = parts[1].trim_matches(['<', '>']);
|
||||
let units: Vec<_> = value
|
||||
.as_bytes()
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|chunk| u16::from_str_radix(std::str::from_utf8(chunk).unwrap(), 16).unwrap())
|
||||
.collect();
|
||||
mapping.insert(cid, String::from_utf16(&units).unwrap());
|
||||
}
|
||||
}
|
||||
let mut extracted = String::new();
|
||||
let mut append = |bytes: &[u8]| {
|
||||
assert!(bytes.len().is_multiple_of(2));
|
||||
for pair in bytes.as_chunks::<2>().0 {
|
||||
extracted.push_str(mapping.get(&u16::from_be_bytes([pair[0], pair[1]])).unwrap());
|
||||
}
|
||||
};
|
||||
let mut operations = page.typed_operations();
|
||||
while let Some(operation) = operations.next() {
|
||||
match operation {
|
||||
TypedInstruction::ShowText(value) => append(value.0.as_bytes()),
|
||||
TypedInstruction::ShowTexts(values) => {
|
||||
for value in values.0.iter::<Object<'_>>() {
|
||||
if let Object::String(value) = value {
|
||||
append(value.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(extracted, text);
|
||||
}
|
||||
|
||||
/// Notes pages and handouts print on the chosen paper, landscape included, with every slide box
|
||||
/// on the page and room left for the notes.
|
||||
#[test]
|
||||
|
||||
@@ -338,7 +338,7 @@ pub fn body_pr(b: &El) -> BodyProps {
|
||||
match e.local() {
|
||||
"normAutofit" => {
|
||||
p.autofit = Some(AutoFit::Shrink {
|
||||
font_scale: pct(e, "fontScale").unwrap_or(1.0).clamp(0.01, 1.0),
|
||||
font_scale: pct(e, "fontScale").map(|v| v.clamp(0.01, 1.0)),
|
||||
line_reduction: pct(e, "lnSpcReduction").unwrap_or(0.0).clamp(0.0, 1.0),
|
||||
})
|
||||
}
|
||||
@@ -645,8 +645,14 @@ mod tests {
|
||||
assert_eq!(b.wrap, Some(false));
|
||||
assert_eq!(b.vert, Some(TextDir::Vertical270));
|
||||
assert_eq!(b.columns, Some(2));
|
||||
assert_eq!(b.autofit, Some(AutoFit::Shrink { font_scale: 0.625, line_reduction: 0.2 }));
|
||||
assert_eq!(b.autofit, Some(AutoFit::Shrink { font_scale: Some(0.625), line_reduction: 0.2 }));
|
||||
assert_eq!(b.space_first_last, None);
|
||||
// A bare `<a:normAutofit/>` keeps no scale: the schema default 100 % is drawn as it is,
|
||||
// not the same as a saved 100 % (`fontScale="100000"`) (#724).
|
||||
let d = parse(br#"<a:bodyPr><a:normAutofit/></a:bodyPr>"#).unwrap();
|
||||
assert_eq!(body_pr(&d.root).autofit, Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 }));
|
||||
let d = parse(br#"<a:bodyPr><a:normAutofit fontScale="100000"/></a:bodyPr>"#).unwrap();
|
||||
assert_eq!(body_pr(&d.root).autofit, Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }));
|
||||
let d = parse(br#"<a:bodyPr spcFirstLastPara="1"/>"#).unwrap();
|
||||
assert_eq!(body_pr(&d.root).space_first_last, Some(true));
|
||||
// mongolianVert is its own direction (PowerPoint runs its lines left to right) and is
|
||||
|
||||
@@ -489,9 +489,11 @@ pub fn body_pr(w: &mut W, b: &BodyProps) {
|
||||
Some(AutoFit::None) => w.empty0("a:noAutofit"),
|
||||
Some(AutoFit::Shape) => w.empty0("a:spAutoFit"),
|
||||
Some(AutoFit::Shrink { font_scale, line_reduction }) => {
|
||||
let fs = pct(font_scale.clamp(0.01, 1.0));
|
||||
// A scale the model holds is written, 100 % included: reopening then keeps the same
|
||||
// "draw the saved scale" state instead of becoming a bare `<a:normAutofit/>` (#724).
|
||||
let fs = font_scale.map(|s| pct(s.clamp(0.01, 1.0)));
|
||||
let lr = pct(line_reduction.clamp(0.0, 1.0));
|
||||
w.empty("a:normAutofit", A::new().o("fontScale", (fs != 100_000).then_some(fs)).o("lnSpcReduction", (lr != 0).then_some(lr)));
|
||||
w.empty("a:normAutofit", A::new().o("fontScale", fs).o("lnSpcReduction", (lr != 0).then_some(lr)));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ fn corpus() -> Presentation {
|
||||
let mut columns = TextBody::from_text("Two columns\nof vertical text");
|
||||
columns.body.columns = Some(3);
|
||||
columns.body.col_spacing = Some(18.0);
|
||||
columns.body.autofit = Some(AutoFit::Shrink { font_scale: 0.85, line_reduction: 0.2 });
|
||||
columns.body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.85), line_reduction: 0.2 });
|
||||
columns.body.vert = Some(TextDir::Vertical270);
|
||||
let mut col = shape(&mut p, "Columns", 480.0, 40.0, 200.0, 160.0);
|
||||
col.text = Some(columns);
|
||||
@@ -176,7 +176,7 @@ fn chart_table_and_text_corpus_round_trips() {
|
||||
assert_eq!(plain(cols), "Two columns\nof vertical text");
|
||||
assert_eq!(cols.body.columns, Some(3));
|
||||
assert_eq!(cols.body.col_spacing, Some(18.0));
|
||||
assert_eq!(cols.body.autofit, Some(AutoFit::Shrink { font_scale: 0.85, line_reduction: 0.2 }));
|
||||
assert_eq!(cols.body.autofit, Some(AutoFit::Shrink { font_scale: Some(0.85), line_reduction: 0.2 }));
|
||||
assert_eq!(cols.body.vert, Some(TextDir::Vertical270));
|
||||
|
||||
let warp = by_name(slide, "Warp").text.as_ref().expect("warp");
|
||||
|
||||
@@ -79,7 +79,7 @@ fn style_deck() -> Presentation {
|
||||
let mut word = shape(&mut p, "Styled text", 40.0, 40.0, 400.0, 180.0);
|
||||
word.text = Some(TextBody {
|
||||
body: BodyProps {
|
||||
autofit: Some(AutoFit::Shrink { font_scale: 0.8, line_reduction: 0.2 }),
|
||||
autofit: Some(AutoFit::Shrink { font_scale: Some(0.8), line_reduction: 0.2 }),
|
||||
columns: Some(2),
|
||||
col_spacing: Some(12.0),
|
||||
vert: Some(TextDir::Vertical270),
|
||||
@@ -167,7 +167,7 @@ fn style_kinds_round_trip() {
|
||||
let twice = round(&once);
|
||||
|
||||
let body = &by_name(&once, "Styled text").text.as_ref().expect("text").body;
|
||||
assert_eq!(body.autofit, Some(AutoFit::Shrink { font_scale: 0.8, line_reduction: 0.2 }));
|
||||
assert_eq!(body.autofit, Some(AutoFit::Shrink { font_scale: Some(0.8), line_reduction: 0.2 }));
|
||||
assert_eq!(body.columns, Some(2));
|
||||
assert_eq!(body.col_spacing, Some(12.0));
|
||||
assert_eq!(body.vert, Some(TextDir::Vertical270));
|
||||
|
||||
@@ -222,6 +222,9 @@ pub struct RenderOpts<'a> {
|
||||
pub skip: &'a [ShapeId],
|
||||
/// Hide the text of this shape (the UI draws it while editing).
|
||||
pub skip_text: Option<ShapeId>,
|
||||
/// Text being edited: replace its prompt with gray markers on empty list paragraphs.
|
||||
/// Ignored outside editor rendering.
|
||||
pub editing_text: Option<ShapeId>,
|
||||
/// Animated GIF pictures and how far into their play they are (seconds); others show frame 1.
|
||||
pub gif_times: &'a [(ShapeId, f64)],
|
||||
/// Worker threads (0 = this thread; required when effects use filters).
|
||||
@@ -246,6 +249,7 @@ impl Default for RenderOpts<'_> {
|
||||
state: None,
|
||||
skip: &[],
|
||||
skip_text: None,
|
||||
editing_text: None,
|
||||
gif_times: &[],
|
||||
threads: 0,
|
||||
offset: (0.0, 0.0),
|
||||
@@ -921,7 +925,7 @@ impl Renderer {
|
||||
}
|
||||
ShapeKind::Table(t) => {
|
||||
// Rows grow with their text: what the cells cover, borders included.
|
||||
let cells = table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields);
|
||||
let cells = table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields, f.opts.edit && f.opts.editing_text == Some(s.id));
|
||||
self.track(m, cells.unwrap_or(Rect::new(0.0, 0.0, x.w, x.h)).inflate(2.0, 2.0));
|
||||
}
|
||||
ShapeKind::Chart(c) => {
|
||||
@@ -1300,8 +1304,21 @@ impl Renderer {
|
||||
&text_geo
|
||||
};
|
||||
let tr = geo.text_rect;
|
||||
if !body.is_empty() {
|
||||
let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
|
||||
let editing = f.opts.edit && f.opts.editing_text == Some(s.id);
|
||||
if !body.is_empty() || editing {
|
||||
// Imported placeholders can have no paragraph until the first keystroke.
|
||||
let mut empty;
|
||||
let body = if editing && body.paragraphs.is_empty() {
|
||||
empty = body.clone();
|
||||
empty.paragraphs.push(Default::default());
|
||||
&empty
|
||||
} else {
|
||||
body
|
||||
};
|
||||
let mut l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
|
||||
if editing {
|
||||
preview_empty_bullets(&mut l);
|
||||
}
|
||||
let tm = text_transform(m, tr, l.rotation);
|
||||
for b in l.lines.iter().filter_map(line_bounds) {
|
||||
// Glyphs reach a little past their carets and line box (italics, swashes).
|
||||
@@ -2034,6 +2051,15 @@ pub fn draw_text(ctx: &mut RenderContext, rctx: &Ctx, s: &Shape, body: &TextBody
|
||||
draw_layout(ctx, &l, tr, m);
|
||||
}
|
||||
|
||||
/// Add transient list markers to an editor layout, using the document's own bullet glyphs.
|
||||
fn preview_empty_bullets(l: &mut deckcraft_text::TextLayout) {
|
||||
for mut run in l.empty_bullets.drain(..) {
|
||||
run.color = Rgba::rgb(0xB3, 0xB3, 0xB3);
|
||||
run.alpha = 1.0;
|
||||
l.runs.push(run);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where text laid out in text rect `tr` is drawn, for a shape drawn with `m` (shape-local →
|
||||
/// output): `m`, without mirroring, then the layout's `rotation` about the rect's centre.
|
||||
///
|
||||
|
||||
+12
-10
@@ -188,16 +188,6 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b
|
||||
return;
|
||||
};
|
||||
let px_scale = crate::scale_of(m);
|
||||
let want = (bounds.width().max(bounds.height()) * px_scale).max(1.0);
|
||||
let frame = gif_time.and_then(|t| images::gif_pixmap(&item.data, &pf.adjust, t));
|
||||
let Some(pm) = frame.or_else(|| images::decode_for(&item.data, want, &pf.adjust)) else {
|
||||
missing(ctx, bounds);
|
||||
return;
|
||||
};
|
||||
let (iw, ih) = (pm.width() as f64, pm.height() as f64);
|
||||
if iw < 1.0 || ih < 1.0 {
|
||||
return;
|
||||
}
|
||||
let dest = match &pf.mode {
|
||||
PictureMode::Stretch { fill_rect } => {
|
||||
let [l, t, r, b] = *fill_rect;
|
||||
@@ -211,6 +201,18 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b
|
||||
}
|
||||
PictureMode::Tile { .. } => bounds,
|
||||
};
|
||||
// Decode for the whole image's size on screen, not the box's: a cropped picture shows only part
|
||||
// of it, magnified (#762).
|
||||
let want = (dest.width().abs().max(dest.height().abs()) * px_scale).max(1.0);
|
||||
let frame = gif_time.and_then(|t| images::gif_pixmap(&item.data, &pf.adjust, t));
|
||||
let Some(pm) = frame.or_else(|| images::decode_for(&item.data, want, &pf.adjust)) else {
|
||||
missing(ctx, bounds);
|
||||
return;
|
||||
};
|
||||
let (iw, ih) = (pm.width() as f64, pm.height() as f64);
|
||||
if iw < 1.0 || ih < 1.0 {
|
||||
return;
|
||||
}
|
||||
let alpha = pf.alpha.unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
let image = vello_cpu::Image {
|
||||
image: vello_cpu::ImageSource::Pixmap(pm.clone()),
|
||||
|
||||
@@ -327,6 +327,7 @@ pub fn draw(
|
||||
_w: f64,
|
||||
_h: f64,
|
||||
fields: &dyn deckcraft_text::Fields,
|
||||
editing: bool,
|
||||
) -> Option<kurbo::Rect> {
|
||||
let _ = pres;
|
||||
let rects = cell_rects(t, &row_heights(rctx, t, fields));
|
||||
@@ -418,14 +419,18 @@ pub fn draw(
|
||||
// Text.
|
||||
for (r, c, rect) in &rects {
|
||||
let Some(cell) = t.cell(*r, *c) else { continue };
|
||||
if cell.text.is_empty() || text_off_target(cell, *rect, m, (ctx.width(), ctx.height())) {
|
||||
if (cell.text.is_empty() && !editing) || text_off_target(cell, *rect, m, (ctx.width(), ctx.height())) {
|
||||
continue;
|
||||
}
|
||||
let body = cell_body(rctx, t, *r, *c, cell);
|
||||
let tmp = Shape { text: Some(body.clone()), ..Default::default() };
|
||||
#[cfg(test)]
|
||||
TEXT_CELLS_DRAWN.with(|n| n.set(n.get() + 1));
|
||||
crate::draw_text(ctx, rctx, &tmp, &body, *rect, m, fields, None);
|
||||
let mut l = deckcraft_text::layout(rctx, &tmp, &body, &deckcraft_text::Opts { rect: *rect, fields, prompt_color: None, no_shrink: false });
|
||||
if editing {
|
||||
crate::preview_empty_bullets(&mut l);
|
||||
}
|
||||
crate::draw_layout(ctx, &l, *rect, m);
|
||||
let _ = peniko::Fill::NonZero;
|
||||
}
|
||||
covered
|
||||
|
||||
@@ -2509,3 +2509,62 @@ fn single_series_column_chart_varies_colours_by_point() {
|
||||
};
|
||||
assert_ne!(render(true), render(false));
|
||||
}
|
||||
|
||||
/// A 1024×1024 PNG of 2 px wide black and white vertical stripes.
|
||||
fn fine_stripes_png() -> Vec<u8> {
|
||||
let img =
|
||||
image::RgbaImage::from_fn(1024, 1024, |x, _| if (x / 2) % 2 == 0 { image::Rgba([0, 0, 0, 255]) } else { image::Rgba([255, 255, 255, 255]) });
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut out, image::ImageFormat::Png).expect("png");
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cropped_picture_is_decoded_at_the_resolution_of_the_whole_image() {
|
||||
// The top-left sixteenth of the image in a 64 pt box: 64 image pixels over 64 output pixels.
|
||||
// Decoding at the box's size instead of the whole image's blurs the stripes to grey (#762).
|
||||
let mut p = Presentation::default();
|
||||
let media = p.add_media("stripes.png", "image/png", fine_stripes_png()).expect("media");
|
||||
let fill = deckcraft_model::style::PictureFill { media, crop: [0.0, 0.0, 15.0 / 16.0, 15.0 / 16.0], ..Default::default() };
|
||||
let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 64.0, 64.0)), kind: ShapeKind::Picture { fill }, ..Default::default() };
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let (mut sharp, mut total) = (0, 0);
|
||||
for y in 110..154 {
|
||||
for x in 110..154 {
|
||||
let v = img.pixel(x, y)[0];
|
||||
total += 1;
|
||||
if !(48..=208).contains(&v) {
|
||||
sharp += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(sharp * 10 >= total * 9, "the stripes stay sharp: {sharp} of {total} pixels black or white");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() {
|
||||
use deckcraft_model::text::{Bullet, Paragraph};
|
||||
let mut para = Paragraph::default();
|
||||
para.props.bullet = Some(Bullet::Char { char: "•".into() });
|
||||
para.props.margin_left = Some(24.0);
|
||||
para.props.indent = Some(-24.0);
|
||||
para.end_props.size = Some(32.0);
|
||||
let sh = Shape {
|
||||
id: ShapeId(901),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 120.0)),
|
||||
text: Some(TextBody { paragraphs: vec![para], ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
let p = deck_with(vec![sh]);
|
||||
let render = |edit, editing_text| render_slide(&p, 0, &RenderOpts { edit, editing_text, ..Default::default() });
|
||||
let plain = render(false, None);
|
||||
assert_eq!(plain.pixels, render(true, None).pixels);
|
||||
assert_eq!(plain.pixels, render(false, Some(ShapeId(901))).pixels, "export ignores editor markers");
|
||||
assert_eq!(plain.pixels, render(true, Some(ShapeId(902))).pixels, "other shapes stay empty");
|
||||
let editing = render(true, Some(ShapeId(901)));
|
||||
let changed: Vec<_> = editing.pixels.as_chunks::<4>().0.iter().zip(plain.pixels.as_chunks::<4>().0).filter(|(a, b)| a != b).collect();
|
||||
assert!(!changed.is_empty(), "an empty bullet must be visible");
|
||||
assert!(changed.iter().any(|(px, _)| px[0] == 179 && px[1] == 179 && px[2] == 179));
|
||||
assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2]));
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ fn copy_opts<'a>(o: &RenderOpts<'a>) -> RenderOpts<'a> {
|
||||
state: o.state,
|
||||
skip: o.skip,
|
||||
skip_text: o.skip_text,
|
||||
editing_text: o.editing_text,
|
||||
gif_times: o.gif_times,
|
||||
threads: o.threads,
|
||||
offset: o.offset,
|
||||
|
||||
+78
-56
@@ -292,6 +292,9 @@ pub struct LineInfo {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct TextLayout {
|
||||
pub runs: Vec<GlyphRun>,
|
||||
/// Empty list markers for the editor only; ordinary rendering and export omit them.
|
||||
/// Their space is still reserved so the caret starts where the first character will.
|
||||
pub empty_bullets: Vec<GlyphRun>,
|
||||
pub decos: Vec<Deco>,
|
||||
pub lines: Vec<LineInfo>,
|
||||
/// Inner text box (insets applied), shape-local.
|
||||
@@ -739,56 +742,52 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
|
||||
let lead_size = lead_style.size;
|
||||
let lead_face = lead_style.face.clone();
|
||||
// Bullet.
|
||||
let bullet = if para.is_empty() && opts.prompt_color.is_none() {
|
||||
None
|
||||
} else {
|
||||
match &props.bullet {
|
||||
Some(Bullet::Char { char }) => {
|
||||
let mut rp = lead.clone();
|
||||
if let Some(f) = &props.bullet_font {
|
||||
rp.font = Some(f.clone());
|
||||
}
|
||||
rp.bold = Some(false);
|
||||
rp.italic = Some(false);
|
||||
if let Some(c) = &props.bullet_color {
|
||||
rp.fill = Some(Fill::solid(c.clone()));
|
||||
}
|
||||
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
|
||||
rp.underline = None;
|
||||
rp.strike = None;
|
||||
rp.highlight = None;
|
||||
let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
|
||||
st.baseline = 0.0;
|
||||
// Symbol fonts (Wingdings) aren't available: map common private-use bullets.
|
||||
let ch = map_symbol_bullet(char, props.bullet_font.as_deref());
|
||||
bullet_face(db, &mut st, &ch);
|
||||
let bullet = match &props.bullet {
|
||||
Some(Bullet::Char { char }) => {
|
||||
let mut rp = lead.clone();
|
||||
if let Some(f) = &props.bullet_font {
|
||||
rp.font = Some(f.clone());
|
||||
}
|
||||
rp.bold = Some(false);
|
||||
rp.italic = Some(false);
|
||||
if let Some(c) = &props.bullet_color {
|
||||
rp.fill = Some(Fill::solid(c.clone()));
|
||||
}
|
||||
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
|
||||
rp.underline = None;
|
||||
rp.strike = None;
|
||||
rp.highlight = None;
|
||||
let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
|
||||
st.baseline = 0.0;
|
||||
// Symbol fonts (Wingdings) aren't available: map common private-use bullets.
|
||||
let ch = map_symbol_bullet(char, props.bullet_font.as_deref());
|
||||
bullet_face(db, &mut st, &ch);
|
||||
|
||||
// A symbol font (Wingdings, Symbol…) that is installed draws its own glyph, found
|
||||
// at U+F000 + the character code as such fonts map them; otherwise common symbol
|
||||
// bullets are mapped to Unicode look-alikes.
|
||||
let ch = match symbol_font_char(&st.face, char, props.bullet_font.as_deref()) {
|
||||
Some(c) => c.to_string(),
|
||||
None => map_symbol_bullet(char, props.bullet_font.as_deref()),
|
||||
};
|
||||
if !st.face.covers(ch.chars().next().unwrap_or('•')) {
|
||||
st.face = db.face("Inter", "Regular");
|
||||
}
|
||||
Some((ch, st))
|
||||
// A symbol font (Wingdings, Symbol…) that is installed draws its own glyph, found
|
||||
// at U+F000 + the character code as such fonts map them; otherwise common symbol
|
||||
// bullets are mapped to Unicode look-alikes.
|
||||
let ch = match symbol_font_char(&st.face, char, props.bullet_font.as_deref()) {
|
||||
Some(c) => c.to_string(),
|
||||
None => map_symbol_bullet(char, props.bullet_font.as_deref()),
|
||||
};
|
||||
if !st.face.covers(ch.chars().next().unwrap_or('•')) {
|
||||
st.face = db.face("Inter", "Regular");
|
||||
}
|
||||
Some(Bullet::AutoNum { scheme, start_at }) => {
|
||||
let mut rp = lead.clone();
|
||||
if let Some(c) = &props.bullet_color {
|
||||
rp.fill = Some(Fill::solid(c.clone()));
|
||||
}
|
||||
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
|
||||
rp.underline = None;
|
||||
let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
|
||||
let label = autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1)));
|
||||
bullet_face(db, &mut st, &label);
|
||||
Some((label, st))
|
||||
}
|
||||
_ => None,
|
||||
Some((ch, st))
|
||||
}
|
||||
Some(Bullet::AutoNum { scheme, start_at }) => {
|
||||
let mut rp = lead.clone();
|
||||
if let Some(c) = &props.bullet_color {
|
||||
rp.fill = Some(Fill::solid(c.clone()));
|
||||
}
|
||||
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
|
||||
rp.underline = None;
|
||||
let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
|
||||
let label = autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1)));
|
||||
bullet_face(db, &mut st, &label);
|
||||
Some((label, st))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
ParaShaped { cells, styles, faces, props, lead_size, lead_face, bullet, level: para.level, rtl }
|
||||
}
|
||||
@@ -1549,18 +1548,21 @@ pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLay
|
||||
let autofit = bp.autofit.unwrap_or(AutoFit::None);
|
||||
// Shrink on overflow starts from the scale the file was saved with (`fontScale`,
|
||||
// `lnSpcReduction`), which is what PowerPoint draws; editing the text resets it.
|
||||
let (scale0, reduce0) = match (autofit, opts.no_shrink) {
|
||||
let (scale0, reduce0, no_saved_scale) = match (autofit, opts.no_shrink) {
|
||||
(AutoFit::Shrink { font_scale, line_reduction }, false) => (
|
||||
if font_scale.is_finite() { font_scale.clamp(0.1, 1.0) } else { 1.0 },
|
||||
font_scale.filter(|s| s.is_finite()).map(|s| s.clamp(0.1, 1.0)).unwrap_or(1.0),
|
||||
if line_reduction.is_finite() { line_reduction.clamp(0.0, 0.2) } else { 0.0 },
|
||||
font_scale.is_none(),
|
||||
),
|
||||
_ => (1.0, 0.0),
|
||||
_ => (1.0, 0.0, false),
|
||||
};
|
||||
let mut l = layout_scaled(ctx, shape, body, &bp, opts, scale0, reduce0);
|
||||
// A scale PowerPoint saved is drawn as it is: PowerPoint fits the text again only when it
|
||||
// changes (and editing resets the saved scale), not when a file opens, even where our
|
||||
// metrics would call it overflowing (trailing empty paragraphs, substituted fonts).
|
||||
let saved = scale0 < 1.0 || reduce0 > 0.0;
|
||||
// A body whose file has no `fontScale` at all (`<a:normAutofit/>`) is the schema default
|
||||
// 100 % and is drawn as it is too, not refitted on open (#724).
|
||||
let saved = no_saved_scale || scale0 < 1.0 || reduce0 > 0.0;
|
||||
if let (AutoFit::Shrink { .. }, false) = (autofit, opts.no_shrink)
|
||||
&& l.overflow
|
||||
&& !saved
|
||||
@@ -1654,6 +1656,10 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
*deeper = 0;
|
||||
}
|
||||
numbering.get(lvl).copied()
|
||||
} else if matches!(pp.bullet, Some(Bullet::AutoNum { .. })) {
|
||||
// Preview the next number without consuming it: empty paragraphs do not advance
|
||||
// numbering in the saved document or affect later populated paragraphs.
|
||||
numbering.get(lvl).map(|n| n.saturating_add(1))
|
||||
} else {
|
||||
if !para.is_empty() {
|
||||
for n in numbering.iter_mut().skip(lvl) {
|
||||
@@ -1908,9 +1914,20 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
// PowerPoint draws justified and distributed anchoring at the bottom.
|
||||
Anchor::Bottom | Anchor::Justified | Anchor::Distributed => inner.height() - used_h,
|
||||
};
|
||||
// anchorCtr: centre the block horizontally.
|
||||
let dx = if bp.anchor_ctr.unwrap_or(false) { ((inner.width() - out.content_width) / 2.0).max(0.0) } else { 0.0 };
|
||||
let ox = inner.x0 + dx;
|
||||
// anchorCtr: centre the drawn text block on the box. Lines sit where their alignment put
|
||||
// them, so the origin is set so the block's centre lands on the box's centre — a left-edge
|
||||
// offset alone pushed right-aligned lines out of the box (#736). Empty lines carry no ink
|
||||
// and stay out of the block. Overflowing content centres symmetrically past both edges.
|
||||
// Not applied to vertical text (there the offset would run along the wrong axis) or
|
||||
// multi-column bodies (centring unimplemented for them).
|
||||
let centre_block = bp.anchor_ctr.unwrap_or(false) && ncols == 1 && matches!(vert, TextDir::Horizontal);
|
||||
let ox = if centre_block && !all_lines.is_empty() {
|
||||
let ink = all_lines.iter().filter(|(li, ..)| li.end > li.start);
|
||||
let (bmin, bmax) = ink.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), (li, ..)| (a.min(li.extent.0), b.max(li.extent.1)));
|
||||
if bmax >= bmin { inner.x0 + inner.width() / 2.0 - (bmin + bmax) / 2.0 } else { inner.x0 }
|
||||
} else {
|
||||
inner.x0
|
||||
};
|
||||
let oy = inner.y0 + dy;
|
||||
// Emit runs, in visual order.
|
||||
for (line, place, _, bullet) in all_lines.iter_mut() {
|
||||
@@ -1930,7 +1947,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
&& let Some((_, st)) = p.bullet.as_ref()
|
||||
{
|
||||
let (_, g) = measure_str(&st.face, st.size, &s);
|
||||
out.runs.push(GlyphRun {
|
||||
let run = GlyphRun {
|
||||
face: st.face.clone(),
|
||||
size: st.size,
|
||||
color: st.color,
|
||||
@@ -1946,7 +1963,12 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
gradient: None,
|
||||
effects: None,
|
||||
upright: false,
|
||||
});
|
||||
};
|
||||
if body.paragraphs.get(line.para).is_some_and(|p| p.is_empty()) && opts.prompt_color.is_none() {
|
||||
out.empty_bullets.push(run);
|
||||
} else {
|
||||
out.runs.push(run);
|
||||
}
|
||||
}
|
||||
// Group visually consecutive cells by (style, face).
|
||||
let mut cur: Option<GlyphRun> = None;
|
||||
|
||||
+143
-6
@@ -186,26 +186,38 @@ fn saved_shrink_scale_is_drawn() {
|
||||
// A file saved with fontScale 62.5% and lnSpcReduction 20% draws at that scale even though
|
||||
// the text would fit at full size, as PowerPoint draws it.
|
||||
let (p, mut sh) = setup("Short", 600.0, 300.0);
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: 0.625, line_reduction: 0.2 });
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.625), line_reduction: 0.2 });
|
||||
let l = lay(&p, &sh, 600.0, 300.0);
|
||||
assert_eq!((l.font_scale, l.line_reduction), (0.625, 0.2));
|
||||
// Text our metrics find overflowing at the saved scale is still drawn at it, as PowerPoint
|
||||
// draws it until the text changes; with no saved scale it shrinks to fit.
|
||||
let text = (0..30).map(|i| format!("Line number {i}")).collect::<Vec<_>>().join("\n");
|
||||
let (p, mut sh) = setup(&text, 600.0, 200.0);
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: 0.9, line_reduction: 0.1 });
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.9), line_reduction: 0.1 });
|
||||
let l = lay(&p, &sh, 600.0, 200.0);
|
||||
assert_eq!((l.font_scale, l.line_reduction), (0.9, 0.1));
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 });
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 });
|
||||
let l = lay(&p, &sh, 600.0, 200.0);
|
||||
assert!(l.font_scale < 0.9, "{}", l.font_scale);
|
||||
// Nonsense saved values are ignored.
|
||||
let (p, mut sh) = setup("Short", 600.0, 300.0);
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: f64::NAN, line_reduction: 5.0 });
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: Some(f64::NAN), line_reduction: 5.0 });
|
||||
let l = lay(&p, &sh, 600.0, 300.0);
|
||||
assert_eq!((l.font_scale, l.line_reduction), (1.0, 0.2));
|
||||
}
|
||||
|
||||
/// A body whose file has `<a:normAutofit/>` with no `fontScale` is the schema default 100 %:
|
||||
/// PowerPoint draws it as it is, even overflowing. DeckCraft draws it the same (#724).
|
||||
#[test]
|
||||
fn bare_normautofit_draws_at_full_size() {
|
||||
let text = (0..30).map(|i| format!("Line number {i}")).collect::<Vec<_>>().join("\n");
|
||||
let (p, mut sh) = setup(&text, 600.0, 200.0);
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 });
|
||||
let l = lay(&p, &sh, 600.0, 200.0);
|
||||
assert_eq!(l.font_scale, 1.0, "a body with no saved fontScale draws at 100%");
|
||||
assert!(l.overflow, "it overflows, as PowerPoint draws it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_line_spacing_ends_at_the_last_descent() {
|
||||
// 12 pt text at exactly 26.59 pt per line (a table cell PowerPoint sizes to 24.3 pt of text):
|
||||
@@ -942,8 +954,11 @@ fn symbol_font_characters_draw_in_the_sym_font() {
|
||||
// U+F0DE is the Symbol font's double arrow (⇒). It must come from that font, not from
|
||||
// whichever installed font happens to map U+F0DE (Wingdings draws an arrow at another angle).
|
||||
let symbol = "Symbol";
|
||||
if !FontDb::global().has_family(symbol) {
|
||||
eprintln!("skipped: no Symbol font installed");
|
||||
let db = FontDb::global();
|
||||
// Some installed Symbol faces use Unicode mappings only, without the legacy U+F0xx cmap.
|
||||
// This integration test needs a face that can draw the symbol code used by the document.
|
||||
if !db.has_family(symbol) || !db.face(symbol, "Regular").covers('\u{F0DE}') {
|
||||
eprintln!("skipped: no installed Symbol face covers U+F0DE");
|
||||
return;
|
||||
}
|
||||
let (p, mut sh) = setup("x\u{F0DE}y", 600.0, 300.0);
|
||||
@@ -1330,3 +1345,125 @@ fn stacked_combining_marks_take_a_face_that_draws_them() {
|
||||
}
|
||||
let _ = f;
|
||||
}
|
||||
|
||||
/// #736: `anchorCtr` centres the text block horizontally, whatever the paragraphs' own
|
||||
/// alignment, and overflowing content centres symmetrically past both edges.
|
||||
#[test]
|
||||
fn anchorctr_centres_the_text_block() {
|
||||
let text = "The shape is top - center - overriding paragraph right alignment.";
|
||||
// The block's centre: (min left + max right) over the laid-out lines, in box coordinates.
|
||||
let block_centre = |l: &TextLayout| -> f64 {
|
||||
// Empty lines carry no ink and sit where their alignment left them: not part of the block.
|
||||
let ink = l.lines.iter().filter(|li| li.end > li.start);
|
||||
let (min, max) = ink.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), li| (a.min(li.extent.0), b.max(li.extent.1)));
|
||||
(min + max) / 2.0
|
||||
};
|
||||
let para = |align: Option<Align>, text: &str| Paragraph {
|
||||
props: ParaProps { align, ..Default::default() },
|
||||
runs: vec![Run::new(text)],
|
||||
..Default::default()
|
||||
};
|
||||
let anchored =
|
||||
|paragraphs: Vec<Paragraph>| TextBody { body: BodyProps { anchor_ctr: Some(true), ..Default::default() }, paragraphs, ..Default::default() };
|
||||
// One right-aligned line in a wide box: the block is centred, not pushed past the right edge.
|
||||
let body_right = anchored(vec![para(Some(Align::Right), text)]);
|
||||
let (p, mut sh) = setup("", 828.0, 104.0);
|
||||
sh.text = Some(body_right);
|
||||
let l = lay(&p, &sh, 828.0, 104.0);
|
||||
assert!((block_centre(&l) - 414.0).abs() <= 1.0, "right-aligned: centre {}, want 414", block_centre(&l));
|
||||
// Left-aligned text centres the same way.
|
||||
let body_left = anchored(vec![para(None, text)]);
|
||||
let (p, mut sh) = setup("", 828.0, 104.0);
|
||||
sh.text = Some(body_left);
|
||||
let l = lay(&p, &sh, 828.0, 104.0);
|
||||
assert!((block_centre(&l) - 414.0).abs() <= 1.0, "left-aligned: centre {}, want 414", block_centre(&l));
|
||||
// A trailing empty paragraph carries no ink and must not skew the block.
|
||||
let body_empty = anchored(vec![para(Some(Align::Right), text), para(None, "")]);
|
||||
let (p, mut sh) = setup("", 828.0, 104.0);
|
||||
sh.text = Some(body_empty);
|
||||
let l = lay(&p, &sh, 828.0, 104.0);
|
||||
assert!((block_centre(&l) - 414.0).abs() <= 1.0, "trailing empty paragraph: centre {}, want 414", block_centre(&l));
|
||||
// Mixed alignments: the drawn block (not either alignment edge) is what centres.
|
||||
let body_mixed = anchored(vec![para(Some(Align::Right), text), para(None, "short")]);
|
||||
let (p, mut sh) = setup("", 828.0, 104.0);
|
||||
sh.text = Some(body_mixed);
|
||||
let l = lay(&p, &sh, 828.0, 104.0);
|
||||
assert!((block_centre(&l) - 414.0).abs() <= 1.0, "mixed: centre {}, want 414", block_centre(&l));
|
||||
// Content wider than the box overflows symmetrically past both edges (no wrap).
|
||||
let body_wide = TextBody {
|
||||
body: BodyProps { anchor_ctr: Some(true), wrap: Some(false), ..Default::default() },
|
||||
paragraphs: vec![para(Some(Align::Right), text)],
|
||||
..Default::default()
|
||||
};
|
||||
let (p, mut sh) = setup("", 300.0, 104.0);
|
||||
sh.text = Some(body_wide);
|
||||
let l = lay(&p, &sh, 300.0, 104.0);
|
||||
assert!((block_centre(&l) - 150.0).abs() <= 2.0, "overflowing: centre {}, want 150", block_centre(&l));
|
||||
// Control: without anchorCtr a right-aligned line still ends at the box's right edge.
|
||||
let body_control = TextBody { body: BodyProps::default(), paragraphs: vec![para(Some(Align::Right), text)], ..Default::default() };
|
||||
let (p, mut sh) = setup(text, 828.0, 104.0);
|
||||
sh.text = Some(body_control);
|
||||
let l = lay(&p, &sh, 828.0, 104.0);
|
||||
assert!(
|
||||
l.lines.iter().all(|li| li.extent.1 > 810.0),
|
||||
"control: right-aligned at the box edge, got {:?}",
|
||||
l.lines.iter().map(|li| li.extent).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_bullets_reserve_the_typed_indent_without_becoming_content() {
|
||||
for rtl in [false, true] {
|
||||
for level in [0, 1, 2] {
|
||||
let (p, mut sh) = setup("First\n", 600.0, 300.0);
|
||||
for para in &mut sh.text.as_mut().unwrap().paragraphs {
|
||||
para.level = level;
|
||||
para.props.rtl = Some(rtl);
|
||||
para.props.align = Some(if rtl { Align::Right } else { Align::Left });
|
||||
}
|
||||
let empty = lay(&p, &sh, 600.0, 300.0);
|
||||
assert_eq!(empty.empty_bullets.len(), 1);
|
||||
assert_eq!(empty.empty_bullets[0].para, 1);
|
||||
assert!(empty.runs.iter().all(|r| r.para == 0), "empty bullet must not be exported");
|
||||
let caret = empty.caret(Pos { para: 1, ch: 0 }).unwrap();
|
||||
let bx = empty.empty_bullets[0].glyphs[0].1;
|
||||
assert!(if rtl { bx >= caret.0 } else { bx < caret.0 });
|
||||
deckcraft_model::edit::insert(sh.text.as_mut().unwrap(), (1, 0), if rtl { "אבג" } else { "Next" }, None);
|
||||
let typed = lay(&p, &sh, 600.0, 300.0);
|
||||
assert!(typed.empty_bullets.is_empty());
|
||||
let after = typed.caret(Pos { para: 1, ch: 0 }).unwrap();
|
||||
assert!(
|
||||
(caret.0 - after.0).abs() < 1e-6 && (caret.1 - after.1).abs() < 1e-6 && (caret.2 - after.2).abs() < 1e-6,
|
||||
"typing must not move the line's start: {caret:?} vs {after:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_numbered_paragraph_previews_the_next_number_without_consuming_it() {
|
||||
let (p, mut sh) = setup("One\n\nTwo", 600.0, 300.0);
|
||||
for para in &mut sh.text.as_mut().unwrap().paragraphs {
|
||||
para.props.bullet = Some(Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 4 });
|
||||
}
|
||||
let l = lay(&p, &sh, 600.0, 300.0);
|
||||
let preview = &l.empty_bullets[0];
|
||||
let next = l.runs.iter().find(|r| r.para == 2 && r.cells.is_empty()).unwrap();
|
||||
let ids = |r: &GlyphRun| r.glyphs.iter().map(|g| g.0).collect::<Vec<_>>();
|
||||
assert_eq!(preview.para, 1);
|
||||
assert_eq!(ids(preview), ids(next), "both should use 5.");
|
||||
let expected = measure_str(&preview.face, preview.size, "5.").1;
|
||||
assert_eq!(ids(preview), expected.iter().map(|g| g.0).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_plain_paragraphs_and_soft_breaks_have_no_bullet_preview() {
|
||||
let (p, mut sh) = setup("", 600.0, 300.0);
|
||||
sh.text.as_mut().unwrap().paragraphs[0].props.bullet = Some(Bullet::None);
|
||||
assert!(lay(&p, &sh, 600.0, 300.0).empty_bullets.is_empty());
|
||||
sh.text = Some(TextBody::from_text("First"));
|
||||
deckcraft_model::edit::insert(sh.text.as_mut().unwrap(), (0, 5), "\u{b}", None);
|
||||
let l = lay(&p, &sh, 600.0, 300.0);
|
||||
assert_eq!(l.lines.len(), 2);
|
||||
assert!(l.empty_bullets.is_empty(), "a soft line break is not a new list item");
|
||||
}
|
||||
|
||||
@@ -181,20 +181,18 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
// One texture pixel in screen points, and the canvas in pixels from the slide's corner.
|
||||
let pixel = vec2(slide_rect.width() / px.0 as f32, slide_rect.height() / px.1 as f32);
|
||||
let visible = visible_region(avail, slide_rect, pixel);
|
||||
let typing_into = st.and_then(|d| d.selection.text.as_ref()).filter(|t| !t.notes).map(|t| t.shape);
|
||||
let tex = match st.map(|d| (d.selection.target, d)) {
|
||||
Some((Target::Slides, d)) => {
|
||||
let idx = d.selection.slide;
|
||||
let doc = d.doc.clone();
|
||||
let frame = anim.as_ref().map(|(tl, t)| (tl, *t));
|
||||
// A placeholder's prompt makes way for the caret once it is being typed into.
|
||||
let typing_into = d.selection.text.as_ref().filter(|t| !t.notes && t.cell.is_none()).map(|t| t.shape);
|
||||
let no_prompt = typing_into.filter(|id| d.shape(*id).and_then(|sh| sh.text.as_ref()).is_none_or(|body| body.is_empty()));
|
||||
app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×, no_prompt, frame)
|
||||
app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×, typing_into, frame)
|
||||
}
|
||||
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible),
|
||||
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible),
|
||||
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible, typing_into),
|
||||
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible, typing_into),
|
||||
Some((t @ (Target::Handout | Target::NotesMaster), d)) => {
|
||||
deckcraft_engine::master_page(&d.doc, t).and_then(|p| master_tex(ui.ctx(), &p, 0, None, px, visible))
|
||||
deckcraft_engine::master_page(&d.doc, t).and_then(|p| master_tex(ui.ctx(), &p, 0, None, px, visible, typing_into))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
@@ -418,6 +416,7 @@ fn master_tex(
|
||||
layout: Option<usize>,
|
||||
px: (u32, u32),
|
||||
visible: deckcraft_render::Region,
|
||||
editing_text: Option<deckcraft_model::ShapeId>,
|
||||
) -> Option<(egui::TextureHandle, deckcraft_render::Region)> {
|
||||
let scale = px.0 as f64 / doc.slide_size.width.max(1.0);
|
||||
let region = crate::textures::fit_texture(
|
||||
@@ -429,7 +428,7 @@ fn master_tex(
|
||||
doc,
|
||||
master,
|
||||
layout,
|
||||
&deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() },
|
||||
&deckcraft_render::RenderOpts { scale, edit: true, editing_text, offset: region.offset(), size: Some(region.size()), ..Default::default() },
|
||||
);
|
||||
Some((crate::textures::upload_editor(ctx, "master-canvas", crate::textures::to_color_image(&img, false)), region))
|
||||
}
|
||||
@@ -1476,6 +1475,61 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The first empty placeholder and the paragraph after Enter keep a list marker and indent.
|
||||
#[test]
|
||||
fn empty_bullet_preview_follows_click_type_enter_and_toggle() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("slide.new", json!({"layout": "titleAndContent"})).unwrap();
|
||||
h.run_steps(3);
|
||||
let st = h.state().session.active().unwrap();
|
||||
let sh = st
|
||||
.shapes()
|
||||
.iter()
|
||||
.find(|s| s.ph_type() == Some(deckcraft_model::PhType::Body) || s.ph_type() == Some(deckcraft_model::PhType::Obj))
|
||||
.unwrap();
|
||||
let id = sh.id;
|
||||
let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
|
||||
let p = screen(h.state(), x.x + 40.0, x.y + 20.0);
|
||||
click(&mut h, p);
|
||||
h.run_steps(3);
|
||||
let caret = |h: &Harness<'_, SlideApp>| {
|
||||
let st = h.state().session.active().unwrap();
|
||||
let ts = st.selection.text.as_ref().expect("click starts typing");
|
||||
assert_eq!(ts.shape, id);
|
||||
deckcraft_engine::cmd::text::caret_line(st, ts).unwrap().0
|
||||
};
|
||||
let first = caret(&h);
|
||||
h.event(Event::Text("First point".into()));
|
||||
h.run_steps(3);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
let second = caret(&h);
|
||||
assert!((first.x - second.x).abs() < 0.01, "empty lines retain the bullet indent");
|
||||
assert!(second.y > first.y);
|
||||
let st = h.state().session.active().unwrap();
|
||||
let l = deckcraft_engine::cmd::text::layout_for(st, st.selection.text.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(l.empty_bullets.len(), 1);
|
||||
assert_eq!(l.empty_bullets[0].para, 1);
|
||||
let renders = h.state().textures.canvas_renders;
|
||||
h.state_mut().run("text.exit", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().textures.canvas_renders > renders, "leaving text editing refreshes the marker texture");
|
||||
h.state_mut().run("text.edit", json!({"id": id, "at": [1, 0]})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.event(Event::Text("Second point".into()));
|
||||
h.run_steps(3);
|
||||
let st = h.state().session.active().unwrap();
|
||||
let ts = st.selection.text.as_ref().unwrap();
|
||||
assert_eq!(deckcraft_engine::cmd::text::body_of(st, ts).unwrap().text(), "First point\nSecond point");
|
||||
assert!(deckcraft_engine::cmd::text::layout_for(st, ts).unwrap().empty_bullets.is_empty());
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("format.bullets", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert!(deckcraft_engine::cmd::text::layout_for(st, st.selection.text.as_ref().unwrap()).unwrap().empty_bullets.is_empty());
|
||||
}
|
||||
|
||||
/// Double-clicking a table cell edits it with the caret drawn in that cell, where clicked.
|
||||
#[test]
|
||||
fn double_click_in_a_table_cell_shows_the_caret_there() {
|
||||
|
||||
@@ -28,12 +28,34 @@ pub struct ControlRequest {
|
||||
pub method: String,
|
||||
pub params: Value,
|
||||
pub reply: Sender<ControlResponse>,
|
||||
/// Who has the request: [`PENDING`], [`TAKEN`] (the app runs it) or [`ABANDONED`] (the
|
||||
/// sender gave up on it, so the app must not run it late: issue #719).
|
||||
pub state: std::sync::Arc<std::sync::atomic::AtomicU8>,
|
||||
}
|
||||
|
||||
/// Queued, not yet run.
|
||||
pub const PENDING: u8 = 0;
|
||||
/// The app has started it: it will answer.
|
||||
pub const TAKEN: u8 = 1;
|
||||
/// Its sender gave up waiting (the reply timeout expired): it is dropped unrun.
|
||||
pub const ABANDONED: u8 = 2;
|
||||
|
||||
impl ControlRequest {
|
||||
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
(Self { method: method.into(), params, reply: tx }, rx)
|
||||
(Self { method: method.into(), params, reply: tx, state: Default::default() }, rx)
|
||||
}
|
||||
|
||||
/// Claim the request for running; false when its sender already gave up on it.
|
||||
pub fn take(&self) -> bool {
|
||||
use std::sync::atomic::Ordering;
|
||||
self.state.compare_exchange(PENDING, TAKEN, Ordering::SeqCst, Ordering::SeqCst).is_ok() || self.state.load(Ordering::SeqCst) == TAKEN
|
||||
}
|
||||
|
||||
/// Give up on the request; false when the app has already started it (it will answer).
|
||||
pub fn abandon(state: &std::sync::atomic::AtomicU8) -> bool {
|
||||
use std::sync::atomic::Ordering;
|
||||
state.compare_exchange(PENDING, ABANDONED, Ordering::SeqCst, Ordering::SeqCst).is_ok() || state.load(Ordering::SeqCst) == ABANDONED
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,3 +424,24 @@ pub fn save_screenshot(app: &mut SlideApp, image: &egui::ColorImage, path: Optio
|
||||
None => json!({"ok": false, "error": "no writer configured"}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
/// `take` and `abandon` are exclusive: whoever wins, the request runs at most once (#719).
|
||||
#[test]
|
||||
fn take_and_abandon_are_exclusive() {
|
||||
let (req, _rx) = ControlRequest::new("m", json!({}));
|
||||
assert!(req.take(), "a pending request is taken");
|
||||
assert!(!ControlRequest::abandon(&req.state), "a taken request is not abandoned");
|
||||
assert!(req.take(), "a taken request stays taken");
|
||||
|
||||
let (req, _rx) = ControlRequest::new("m", json!({}));
|
||||
assert!(ControlRequest::abandon(&req.state), "a pending request is abandoned");
|
||||
assert!(!req.take(), "an abandoned request is never taken");
|
||||
assert!(ControlRequest::abandon(&req.state), "abandoning twice stays abandoned");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,9 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) {
|
||||
let escape = ctx.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
if open && !close && !escape {
|
||||
app.dialog = Some(d);
|
||||
} else if !close && d.id == "preferences" {
|
||||
// Esc or the window's close button: the same as Cancel.
|
||||
restore_preferences(app, &d);
|
||||
} else if app.dialog.is_none()
|
||||
&& let Some(ret) = d.params.get("returnParams").filter(|v| v.is_object()).cloned()
|
||||
{
|
||||
@@ -137,6 +140,31 @@ fn buttons(ui: &mut Ui, ok_label: &str) -> (bool, bool) {
|
||||
(ok, cancel)
|
||||
}
|
||||
|
||||
/// What the Preferences dialog can change, as it was when the dialog opened.
|
||||
fn preferences_snapshot(app: &SlideApp) -> Value {
|
||||
json!({
|
||||
"dark": app.ui.brightness == theme::Brightness::Dark,
|
||||
"uiScale": app.ui.ui_scale,
|
||||
"prefs": serde_json::to_value(&app.session.prefs).unwrap_or(Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
/// Puts back the settings the Preferences dialog opened with (Cancel, Esc, the close button).
|
||||
fn restore_preferences(app: &mut SlideApp, d: &Dialog) {
|
||||
let Some(before) = d.params.get("before") else { return };
|
||||
if let Some(dark) = before.get("dark").and_then(Value::as_bool)
|
||||
&& dark != (app.ui.brightness == theme::Brightness::Dark)
|
||||
{
|
||||
let _ = app.run("view.dark", json!({ "on": dark }));
|
||||
}
|
||||
if let Some(scale) = before.get("uiScale").and_then(Value::as_f64) {
|
||||
app.ui.ui_scale = scale as f32;
|
||||
}
|
||||
if let Some(prefs) = before.get("prefs").and_then(|p| serde_json::from_value(p.clone()).ok()) {
|
||||
app.session.prefs = prefs;
|
||||
}
|
||||
}
|
||||
|
||||
fn field(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: &str) {
|
||||
let mut v = d.get(key, default);
|
||||
ui.horizontal(|ui| {
|
||||
@@ -2104,14 +2132,31 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
"gridOptions" => grid_options(app, ui, d),
|
||||
"preferences" => {
|
||||
// The controls apply as they change, so the dialog remembers what it opened with and
|
||||
// Cancel (or Esc, or the close button: see `show`) puts that back.
|
||||
if d.params.get("before").is_none() {
|
||||
d.params = json!({ "before": preferences_snapshot(app) });
|
||||
}
|
||||
ui.label(egui::RichText::new("General").font(theme::bold(13.0)));
|
||||
let mut dark = app.ui.brightness == theme::Brightness::Dark;
|
||||
if ui.checkbox(&mut dark, "Dark appearance").changed() {
|
||||
let _ = app.run("view.dark", json!({"on": dark}));
|
||||
}
|
||||
let mut scale = app.ui.ui_scale;
|
||||
if ui.add(egui::Slider::new(&mut scale, 0.75..=1.75).text("Interface size")).changed() {
|
||||
app.ui.ui_scale = scale;
|
||||
// While the pointer holds the slider its value only waits in the dialog: applying it
|
||||
// would rescale the slider under the pointer and run it to an end (#767). It applies
|
||||
// on release, and at once from the keyboard.
|
||||
let pending = d.fields.get("uiScale").and_then(|v| v.parse::<f32>().ok());
|
||||
let mut scale = pending.unwrap_or(app.ui.ui_scale);
|
||||
let r = ui.add(egui::Slider::new(&mut scale, 0.75..=1.75).text("Interface size"));
|
||||
if r.is_pointer_button_down_on() {
|
||||
if r.changed() || pending.is_some() {
|
||||
d.fields.insert("uiScale".into(), scale.to_string());
|
||||
}
|
||||
} else {
|
||||
if r.changed() || pending.is_some() {
|
||||
app.ui.ui_scale = scale;
|
||||
}
|
||||
d.fields.remove("uiScale");
|
||||
}
|
||||
ui.label(egui::RichText::new("Editing").font(theme::bold(13.0)));
|
||||
ui.checkbox(&mut app.session.prefs.smart_guides, "Show smart guides");
|
||||
@@ -2130,8 +2175,11 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ui.label("Your name (comments):");
|
||||
ui.text_edit_singleline(&mut app.session.prefs.author);
|
||||
});
|
||||
let (ok, _) = buttons(ui, "Done");
|
||||
ok
|
||||
let (ok, cancel) = buttons(ui, "Done");
|
||||
if cancel {
|
||||
restore_preferences(app, d);
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
"photoAlbum" => photo_album_dialog(app, ui, d),
|
||||
"themeColors" => theme_colors_dialog(app, ui, d),
|
||||
|
||||
@@ -1879,6 +1879,10 @@ impl SlideApp {
|
||||
fn drain_control(&mut self, ctx: &egui::Context) {
|
||||
let Some(rx) = self.control_rx.take() else { return };
|
||||
while let Ok(req) = rx.try_recv() {
|
||||
// A request whose sender gave up waiting (reply timeout) must not run late (#719).
|
||||
if !req.take() {
|
||||
continue;
|
||||
}
|
||||
let reply = req.reply.clone();
|
||||
match control::handle(self, ctx, &req) {
|
||||
control::Outcome::Done(v) => {
|
||||
@@ -2573,6 +2577,71 @@ mod tests {
|
||||
assert!((harness.state().session.prefs.grid_spacing - 6.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// Preferences (#752): Cancel, Esc and the window's close button put back what the dialog
|
||||
/// changed while it was open; Done keeps it.
|
||||
#[test]
|
||||
fn preferences_cancel_restores_the_settings_done_keeps_them() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
let before = harness.state().session.prefs.clone();
|
||||
let dark_before = harness.state().ui.brightness;
|
||||
let change = |harness: &mut egui_kittest::Harness<'static, SlideApp>| {
|
||||
harness.state_mut().run("app.preferences", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
for label in ["Dark appearance", "Show smart guides", "Snap objects to grid"] {
|
||||
harness
|
||||
.query_all_by_role_and_label(egui::accesskit::Role::CheckBox, label)
|
||||
.next()
|
||||
.unwrap_or_else(|| panic!("no {label} checkbox"))
|
||||
.click_accesskit();
|
||||
harness.run_steps(2);
|
||||
}
|
||||
assert_ne!(harness.state().ui.brightness, dark_before, "the dialog previews the appearance");
|
||||
assert_ne!(harness.state().session.prefs.smart_guides, before.smart_guides);
|
||||
};
|
||||
let unchanged = |harness: &egui_kittest::Harness<'static, SlideApp>, how: &str| {
|
||||
let s = harness.state();
|
||||
assert!(s.dialog.is_none(), "{how} closes Preferences");
|
||||
assert_eq!(s.ui.brightness, dark_before, "{how} restores the appearance");
|
||||
assert_eq!(s.session.prefs.smart_guides, before.smart_guides, "{how} restores smart guides");
|
||||
assert_eq!(s.session.prefs.snap_to_grid, before.snap_to_grid, "{how} restores snap to grid");
|
||||
};
|
||||
|
||||
change(&mut harness);
|
||||
harness
|
||||
.query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel")
|
||||
.next()
|
||||
.unwrap_or_else(|| panic!("no Cancel button"))
|
||||
.click_accesskit();
|
||||
harness.run_steps(2);
|
||||
unchanged(&harness, "Cancel");
|
||||
|
||||
change(&mut harness);
|
||||
harness.key_press(egui::Key::Escape);
|
||||
harness.run_steps(2);
|
||||
unchanged(&harness, "Esc");
|
||||
|
||||
change(&mut harness);
|
||||
harness
|
||||
.query_all_by_role_and_label(egui::accesskit::Role::Button, "Done")
|
||||
.next()
|
||||
.unwrap_or_else(|| panic!("no Done button"))
|
||||
.click_accesskit();
|
||||
harness.run_steps(2);
|
||||
let s = harness.state();
|
||||
assert!(s.dialog.is_none(), "Done closes Preferences");
|
||||
assert_ne!(s.ui.brightness, dark_before, "Done keeps the appearance");
|
||||
assert_ne!(s.session.prefs.smart_guides, before.smart_guides, "Done keeps smart guides");
|
||||
assert_ne!(s.session.prefs.snap_to_grid, before.snap_to_grid, "Done keeps snap to grid");
|
||||
}
|
||||
|
||||
fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> {
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
@@ -3253,4 +3322,38 @@ mod tests {
|
||||
flick(&mut harness, egui::vec2(-24.0, 0.0));
|
||||
assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(0));
|
||||
}
|
||||
|
||||
/// Preferences › Interface size (#767): dragging the slider leaves the interface size alone
|
||||
/// until the pointer lets go, so the slider doesn't grow under the pointer mid-drag.
|
||||
#[test]
|
||||
fn interface_size_applies_when_the_slider_is_let_go() {
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
harness.state_mut().run("app.preferences", json!({})).unwrap();
|
||||
harness.run_steps(4);
|
||||
let before = harness.state().ui.ui_scale;
|
||||
let rect =
|
||||
harness.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Interface size").next().expect("the Interface size slider").rect();
|
||||
let (from, to) = (rect.left_center() + egui::vec2(4.0, 0.0), rect.right_center() - egui::vec2(4.0, 0.0));
|
||||
harness.hover_at(from);
|
||||
harness.run_steps(1);
|
||||
harness.drag_at(from);
|
||||
harness.run_steps(1);
|
||||
for k in 1..=8 {
|
||||
harness.hover_at(from.lerp(to, k as f32 / 8.0));
|
||||
harness.run_steps(1);
|
||||
}
|
||||
assert_eq!(harness.state().ui.ui_scale, before, "nothing rescales while the slider is held");
|
||||
harness.drop_at(to);
|
||||
harness.run_steps(2);
|
||||
let after = harness.state().ui.ui_scale;
|
||||
assert!(after > before + 0.3, "letting go applies the size dragged to: {before} -> {after}");
|
||||
assert!(harness.state().dialog.is_some(), "Preferences stays open");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ struct Key {
|
||||
extra: u64,
|
||||
/// The pasteboard region the slide's drawing calls for (canvas only).
|
||||
want: Region,
|
||||
skip_text: Option<ShapeId>,
|
||||
editing_text: Option<ShapeId>,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
@@ -248,8 +248,7 @@ impl Textures {
|
||||
/// The canvas texture for slide `index`, the slide being `size` pixels, and the region it
|
||||
/// covers: the slide, grown over the pasteboard to show what reaches off the slide within
|
||||
/// `visible` (the canvas, in pixels from the slide's top-left corner). `extra` changes with the
|
||||
/// GIF frames. `skip_text` leaves out one shape's text (the prompt of a placeholder being typed
|
||||
/// into).
|
||||
/// GIF frames. `editing_text` replaces its placeholder prompt with gray empty list markers.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn slide(
|
||||
&mut self,
|
||||
@@ -262,7 +261,7 @@ impl Textures {
|
||||
extra: u64,
|
||||
grayscale: bool,
|
||||
gif_times: &[(ShapeId, f64)],
|
||||
skip_text: Option<ShapeId>,
|
||||
editing_text: Option<ShapeId>,
|
||||
anim: Option<(&deckcraft_anim::Timeline, f64)>,
|
||||
) -> Option<(TextureHandle, Region)> {
|
||||
let slide = p.slides.get(index)?.clone();
|
||||
@@ -290,7 +289,7 @@ impl Textures {
|
||||
edit,
|
||||
extra: extra ^ grayscale as u64 ^ anim_key(anim),
|
||||
want,
|
||||
skip_text,
|
||||
editing_text,
|
||||
};
|
||||
if let Some(e) = &self.canvas
|
||||
&& e.key == key
|
||||
@@ -309,7 +308,7 @@ impl Textures {
|
||||
offset: region.offset(),
|
||||
size: Some(region.size()),
|
||||
state,
|
||||
skip_text,
|
||||
editing_text,
|
||||
..Default::default()
|
||||
};
|
||||
deckcraft_render::render_slide_drawn(p, index, &opts)
|
||||
@@ -367,7 +366,7 @@ impl Textures {
|
||||
edit: false,
|
||||
extra: 0,
|
||||
want: Region::slide(width_px, h),
|
||||
skip_text: None,
|
||||
editing_text: None,
|
||||
};
|
||||
self.clock += 1;
|
||||
let clock = self.clock;
|
||||
|
||||
@@ -19,6 +19,10 @@ use deckcraft_fonts::{FontDb, FontFace};
|
||||
/// 55 MB, the only Hangul face) and `PingFang.ttc` (about 78 MB, Simplified Chinese).
|
||||
const MAX_FACE_BYTES: usize = 128 << 20;
|
||||
|
||||
fn accepts_face(byte_len: usize, color: bool) -> bool {
|
||||
!color && byte_len <= MAX_FACE_BYTES
|
||||
}
|
||||
|
||||
/// Faces borrowed for the interface, and the characters already looked at.
|
||||
#[derive(Default)]
|
||||
pub struct UiFonts {
|
||||
@@ -53,7 +57,7 @@ pub fn faces_for_text(
|
||||
continue;
|
||||
}
|
||||
let Some(f) = lookup(c) else { continue };
|
||||
if f.is_color() || f.data().len() > MAX_FACE_BYTES || have.iter().chain(&out).any(|h| h.id() == f.id()) {
|
||||
if !accepts_face(f.data().len(), f.is_color()) || have.iter().chain(&out).any(|h| h.id() == f.id()) {
|
||||
continue;
|
||||
}
|
||||
out.push(f);
|
||||
@@ -69,7 +73,13 @@ impl UiFonts {
|
||||
}
|
||||
let font = egui::FontId::proportional(13.0);
|
||||
let new = ctx.fonts_mut(|f| {
|
||||
faces_for_text(text, &self.faces, &mut self.seen, |c| f.has_glyph(&font, c), |c| FontDb::global().fallback_for(c, u32::MAX))
|
||||
faces_for_text(
|
||||
text,
|
||||
&self.faces,
|
||||
&mut self.seen,
|
||||
|c| f.has_glyph(&font, c),
|
||||
|c| FontDb::global().fallback_for_filtered(c, u32::MAX, |face| accepts_face(face.data().len(), face.is_color())),
|
||||
)
|
||||
});
|
||||
if !new.is_empty() {
|
||||
log::debug!("interface fallback fonts: {}", new.iter().map(|f| f.family.as_str()).collect::<Vec<_>>().join(", "));
|
||||
@@ -168,6 +178,14 @@ mod tests {
|
||||
FontDb::global().face(family, "Regular")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_face_policy_rejects_colour_and_oversize_faces() {
|
||||
assert!(accepts_face(MAX_FACE_BYTES, false), "the documented size boundary remains accepted");
|
||||
assert!(!accepts_face(MAX_FACE_BYTES + 1, false), "an oversized outline face is still rejected");
|
||||
assert!(!accepts_face(1, true), "even a small colour face is still rejected");
|
||||
assert!(!accepts_face(MAX_FACE_BYTES + 1, true));
|
||||
}
|
||||
|
||||
/// One face for a whole string, looked up once per character, never for characters the
|
||||
/// interface already draws, and never twice.
|
||||
#[test]
|
||||
|
||||
@@ -19,7 +19,7 @@ nothing below L6 uses egui, eframe, winit or rfd.
|
||||
| L1 | `model`, `fonts`; `h264`, `hevc`, `vp9`, `av1`, `isobmff` | Presentation, masters, layouts, slides, shapes, text, themes, tables, charts, transitions, animations, placeholder inheritance (`resolve`); font database (craft-fonts + system) with metric-compatible substitutes for Office fonts (Carlito for Calibri, Caladea for Cambria, Liberation for Arial/Times); video decoders and the MP4/MOV demuxer |
|
||||
| L2 | `text`, `anim`, `format`, `media` | Shaping (HarfRust), UAX #9 bidi, UAX #14 line breaking, autofit, caret hit testing; per-shape animation state over time, the show state machine, transition frame layouts, Morph matching; `.deckcraft` (zip + JSON + media) and outline text; media probing, decoding, mixer and clock |
|
||||
| L3 | `render`, `pptx` | `vello_cpu` raster of slides, masters and layouts (fills, outlines, effects, pictures, tables, charts, Morph frames); PPTX import/export from ECMA-376 |
|
||||
| L4 | `pdf` | PDF export through krilla |
|
||||
| L4 | `pdf` | App-owned export layout and rendering through the shared `craft-pdf` writer |
|
||||
| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (320 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) |
|
||||
| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 41 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app |
|
||||
| apps | `deckcraft` (desktop: eframe/wgpu, cpal audio, logging), `deckcraft-cli` (run, render, convert, mcp, app), `deckcraft-web` (trunk; WebGPU with WebGL2 fallback), `xtask` | Exempt from layering |
|
||||
@@ -94,3 +94,9 @@ macOS, Windows, Linux, FreeBSD and web artifacts.
|
||||
| 2026-10-14 | minor | Font dialog; command registry 227; 28 dialogs |
|
||||
| 2026-10-13 | minor | Transitions: perspective meshes, background/content parts, CPU compositor |
|
||||
| 2026-10-10 | major | Created from the code on main (d556ff9) and AGENTS.md §4 |
|
||||
|
||||
The PDF writer dependency enables only `write`. Krilla handles serialization and simple-text
|
||||
shaping for notes and footers; slide layout, rasterization, font selection, text mapping,
|
||||
links and outlines remain app-owned. PDF embedding and all Hayro production dependencies
|
||||
are disabled. Native builds enable parallel compression on the same pinned Krilla backend;
|
||||
WASM keeps it disabled. Generated-PDF assertions use a std-only parser as a development dependency.
|
||||
|
||||
@@ -3,10 +3,18 @@
|
||||
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.
|
||||
|
||||
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
|
||||
replies invalidate the connection and leave the command's outcome unknown; the client does not
|
||||
replay it. Unsolicited bytes already buffered between calls force a reconnect before sending.
|
||||
|
||||
The server bounds what a client can hold: 16 connections at once (more get `{"ok":false,"error":"busy: …"}`
|
||||
and are closed), 16 MiB per request line (longer closes the connection), 64 queued requests (more are
|
||||
answered `busy`), 10 minutes idle and 30 seconds per blocked reply write before the socket is closed, and
|
||||
60 seconds waiting for the UI (`"error":"timeout"`).
|
||||
60 seconds waiting for the UI (`"error":"timeout"`). A request still queued when that wait expires is
|
||||
abandoned: the UI skips it, so a retry cannot run it twice. If the UI had already started it, the reply
|
||||
says the outcome is unknown instead of `timeout`.
|
||||
|
||||
```json
|
||||
{"id": 1, "method": "engine.execute", "params": {"command": "shape.insert", "params": {"preset": "roundRect", "rect": [100, 100, 240, 120], "text": "Hello"}}}
|
||||
|
||||
@@ -355,6 +355,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 2026-10-13 | minor | Embedded fonts are read, written and drawn (TrueType, OpenType, Embedded OpenType). The gap is now MicroType Express subsets, which are kept but not drawn |
|
||||
|
||||
+12
@@ -5,6 +5,18 @@ headless engine (no window); with `--connect PORT` it drives a running DeckCraft
|
||||
`--control PORT`, so an agent and a person can work on the same deck. `--sample` opens the sample
|
||||
deck.
|
||||
|
||||
Connected mode accepts only newline-terminated replies of at most 16 MiB (including the newline),
|
||||
with the current request's numeric `id`, a boolean `ok`, and either `result` on success or a string
|
||||
`error` on failure. CRLF is accepted. Invalid replies close the connection and report that the
|
||||
command may or may not have run; they never cause a replay. Check the document before retrying a
|
||||
mutation. Before sending another request, unsolicited bytes in the reader's buffer or on the
|
||||
socket cause a fresh connection to be opened. Large image replies must fit this budget; render
|
||||
to a file or use a smaller scale when necessary.
|
||||
|
||||
Regression coverage for #718 lives in `crates/mcp/src/backend.rs`: synthetic loopback peers test
|
||||
reply correlation, envelopes, framing, the size cap and buffered unsolicited bytes, alongside the
|
||||
lost-reply and reconnect tests.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PowerPoint feature checklist
|
||||
|
||||
> **Generated:** 2026-10-14 (summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Generated:** 2026-10-14 (bullets/numbering remain D after empty-line marker review; summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
The summary between `<!-- SUMMARY -->
|
||||
**Weighted breadth parity: 84%** over 191 features.
|
||||
@@ -145,7 +145,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
||||
| Text | Change case, grow/shrink font, clear formatting | P1 | D |
|
||||
| Text | Text outline and text effects | P2 | P |
|
||||
| Text | Paragraph alignment, indents, spacing, line spacing | P0 | D |
|
||||
| Text | Bullets and numbering | P0 | D |
|
||||
| Text | Bullets and numbering (including gray empty-line markers while editing) | P0 | D |
|
||||
| Text | Picture bullets, custom bullet symbols | P2 | P |
|
||||
| Text | List levels (Tab / Shift+Tab) | P0 | D |
|
||||
| Text | Tab stops | P2 | D |
|
||||
@@ -228,7 +228,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
||||
| Undo & clipboard | Unlimited undo/redo | P0 | D |
|
||||
| Undo & clipboard | Cut, copy, paste shapes and slides | P0 | D |
|
||||
| Undo & clipboard | Paste special (keep formatting, picture, text only) | P1 | D |
|
||||
| Automation (✱) | Every command scriptable (CLI, control channel, MCP) | P0 | D |
|
||||
| Automation (✱) | Every command scriptable (CLI, control channel, MCP; remote reply IDs, envelopes and framing validated, #718) | P0 | D |
|
||||
| Automation (✱) | Headless rendering and conversion | P0 | D |
|
||||
| Platforms (✱) | macOS, Windows, Linux builds | P0 | P |
|
||||
| Platforms (✱) | Web (WASM) build | P1 | D |
|
||||
|
||||
@@ -115,6 +115,7 @@ plan above.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 2026-10-13 | minor | M9: the WordArt Transform gallery applies a text warp; adjust handles stay open |
|
||||
@@ -138,3 +139,6 @@ plan above.
|
||||
| 2026-10-14 | trivial | After beta: pen pressure scales ink width; tilt and pinch zoom remain |
|
||||
| 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha |
|
||||
| 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history |
|
||||
|
||||
PDF writer consolidation preserves the current raster-artwork export and its selectable
|
||||
text layer, notes and handouts. It does not advance vector-PDF or printing parity.
|
||||
|
||||
@@ -7,6 +7,13 @@ direct manipulation, keyboard and precision. Observations of PowerPoint are kept
|
||||
`plan/powerpoint/01-observed-ui.md` and `menu-tree.txt` (gitignored, clean-room rules in
|
||||
AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to parity.
|
||||
|
||||
## Empty list paragraphs
|
||||
|
||||
While editing slide text, empty bulleted and numbered paragraphs show gray markers at the list's
|
||||
indent, including the first click into a content placeholder and the paragraph after Enter.
|
||||
Typing gives the marker its normal text color. These temporary markers are hidden when editing
|
||||
ends and are omitted from thumbnails, slide shows and exports.
|
||||
|
||||
## Shell
|
||||
|
||||
| Element | PowerPoint for Mac | DeckCraft | Gap |
|
||||
@@ -158,6 +165,7 @@ consistency is open (#73, #56).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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: one row each for Format Shape, Dialogs (41, listed from `dialogs.rs`), Animation Pane (timeline is in), window, Quick Access Toolbar, Format Background, rulers, image paste, context menus, exact size and edit points; estimates re-totalled (~26–46 h) |
|
||||
| 2026-10-13 | trivial | Eyedropper in the colour menus (#367) |
|
||||
| 2026-10-12 | minor | Title bar: an Export button next to Save opens the Export dialog, a quick-access shortcut beside File › Export |
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
CC0 1.0 Universal
|
||||
|
||||
Statement of Purpose
|
||||
|
||||
The laws of most jurisdictions throughout the world automatically confer
|
||||
exclusive Copyright and Related Rights (defined below) upon the creator and
|
||||
subsequent owner(s) (each and all, an "owner") of an original work of
|
||||
authorship and/or a database (each, a "Work").
|
||||
|
||||
Certain owners wish to permanently relinquish those rights to a Work for the
|
||||
purpose of contributing to a commons of creative, cultural and scientific
|
||||
works ("Commons") that the public can reliably and without fear of later
|
||||
claims of infringement build upon, modify, incorporate in other works, reuse
|
||||
and redistribute as freely as possible in any form whatsoever and for any
|
||||
purposes, including without limitation commercial purposes. These owners may
|
||||
contribute to the Commons to promote the ideal of a free culture and the
|
||||
further production of creative, cultural and scientific works, or to gain
|
||||
reputation or greater distribution for their Work in part through the use and
|
||||
efforts of others.
|
||||
|
||||
For these and/or other purposes and motivations, and without any expectation
|
||||
of additional consideration or compensation, the person associating CC0 with a
|
||||
Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
|
||||
and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
|
||||
and publicly distribute the Work under its terms, with knowledge of his or her
|
||||
Copyright and Related Rights in the Work and the meaning and intended legal
|
||||
effect of CC0 on those rights.
|
||||
|
||||
1. Copyright and Related Rights. A Work made available under CC0 may be
|
||||
protected by copyright and related or neighboring rights ("Copyright and
|
||||
Related Rights"). Copyright and Related Rights include, but are not limited
|
||||
to, the following:
|
||||
|
||||
i. the right to reproduce, adapt, distribute, perform, display, communicate,
|
||||
and translate a Work;
|
||||
|
||||
ii. moral rights retained by the original author(s) and/or performer(s);
|
||||
|
||||
iii. publicity and privacy rights pertaining to a person's image or likeness
|
||||
depicted in a Work;
|
||||
|
||||
iv. rights protecting against unfair competition in regards to a Work,
|
||||
subject to the limitations in paragraph 4(a), below;
|
||||
|
||||
v. rights protecting the extraction, dissemination, use and reuse of data in
|
||||
a Work;
|
||||
|
||||
vi. database rights (such as those arising under Directive 96/9/EC of the
|
||||
European Parliament and of the Council of 11 March 1996 on the legal
|
||||
protection of databases, and under any national implementation thereof,
|
||||
including any amended or successor version of such directive); and
|
||||
|
||||
vii. other similar, equivalent or corresponding rights throughout the world
|
||||
based on applicable law or treaty, and any national implementations thereof.
|
||||
|
||||
2. Waiver. To the greatest extent permitted by, but not in contravention of,
|
||||
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
|
||||
unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
|
||||
and Related Rights and associated claims and causes of action, whether now
|
||||
known or unknown (including existing as well as future claims and causes of
|
||||
action), in the Work (i) in all territories worldwide, (ii) for the maximum
|
||||
duration provided by applicable law or treaty (including future time
|
||||
extensions), (iii) in any current or future medium and for any number of
|
||||
copies, and (iv) for any purpose whatsoever, including without limitation
|
||||
commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
|
||||
the Waiver for the benefit of each member of the public at large and to the
|
||||
detriment of Affirmer's heirs and successors, fully intending that such Waiver
|
||||
shall not be subject to revocation, rescission, cancellation, termination, or
|
||||
any other legal or equitable action to disrupt the quiet enjoyment of the Work
|
||||
by the public as contemplated by Affirmer's express Statement of Purpose.
|
||||
|
||||
3. Public License Fallback. Should any part of the Waiver for any reason be
|
||||
judged legally invalid or ineffective under applicable law, then the Waiver
|
||||
shall be preserved to the maximum extent permitted taking into account
|
||||
Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
|
||||
is so judged Affirmer hereby grants to each affected person a royalty-free,
|
||||
non transferable, non sublicensable, non exclusive, irrevocable and
|
||||
unconditional license to exercise Affirmer's Copyright and Related Rights in
|
||||
the Work (i) in all territories worldwide, (ii) for the maximum duration
|
||||
provided by applicable law or treaty (including future time extensions), (iii)
|
||||
in any current or future medium and for any number of copies, and (iv) for any
|
||||
purpose whatsoever, including without limitation commercial, advertising or
|
||||
promotional purposes (the "License"). The License shall be deemed effective as
|
||||
of the date CC0 was applied by Affirmer to the Work. Should any part of the
|
||||
License for any reason be judged legally invalid or ineffective under
|
||||
applicable law, such partial invalidity or ineffectiveness shall not
|
||||
invalidate the remainder of the License, and in such case Affirmer hereby
|
||||
affirms that he or she will not (i) exercise any of his or her remaining
|
||||
Copyright and Related Rights in the Work or (ii) assert any associated claims
|
||||
and causes of action with respect to the Work, in either case contrary to
|
||||
Affirmer's express Statement of Purpose.
|
||||
|
||||
4. Limitations and Disclaimers.
|
||||
|
||||
a. No trademark or patent rights held by Affirmer are waived, abandoned,
|
||||
surrendered, licensed or otherwise affected by this document.
|
||||
|
||||
b. Affirmer offers the Work as-is and makes no representations or warranties
|
||||
of any kind concerning the Work, express, implied, statutory or otherwise,
|
||||
including without limitation warranties of title, merchantability, fitness
|
||||
for a particular purpose, non infringement, or the absence of latent or
|
||||
other defects, accuracy, or the present or absence of errors, whether or not
|
||||
discoverable, all to the greatest extent permissible under applicable law.
|
||||
|
||||
c. Affirmer disclaims responsibility for clearing rights of other persons
|
||||
that may apply to the Work or any use thereof, including without limitation
|
||||
any person's Copyright and Related Rights in the Work. Further, Affirmer
|
||||
disclaims responsibility for obtaining any necessary consents, permissions
|
||||
or other rights required for any use of the Work.
|
||||
|
||||
d. Affirmer understands and acknowledges that Creative Commons is not a
|
||||
party to this document and has no duty or obligation with respect to this
|
||||
CC0 or use of the Work.
|
||||
|
||||
For more information, please see
|
||||
http://creativecommons.org/publicdomain/zero/1.0/
|
||||
@@ -37,6 +37,12 @@
|
||||
<keyword>slide show</keyword>
|
||||
<keyword>pptx</keyword>
|
||||
</keywords>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Slide editor with thumbnails, a process diagram and notes</caption>
|
||||
<image type="source" width="1600" height="1000">https://raw.githubusercontent.com/storytold/deckcraft/main/docs/images/editor.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<branding>
|
||||
<color type="primary" scheme_preference="light">#ffc9a8</color>
|
||||
<color type="primary" scheme_preference="dark">#b8471f</color>
|
||||
|
||||
+5
-1
@@ -8,6 +8,7 @@ mod bench;
|
||||
mod ico;
|
||||
mod layers;
|
||||
mod parity;
|
||||
mod pdf_deps;
|
||||
mod stats;
|
||||
mod version;
|
||||
|
||||
@@ -19,9 +20,10 @@ usage: cargo xtask <command>
|
||||
|
||||
commands:
|
||||
assets check that every icon/image/font/asset is attributed in ATTRIBUTION.md
|
||||
pdf-deps verify pinned writer sources and the production feature boundary
|
||||
layers enforce the crate dependency layering (plan/architecture.md §3)
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
|
||||
ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
|
||||
ci fmt --check, pdf-deps, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
|
||||
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
|
||||
bench [--save] [DECK...]
|
||||
time open, render, save and export of the sample (and DECKs) in release, and
|
||||
@@ -37,6 +39,7 @@ fn main() -> ExitCode {
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("assets") => assets::run(&root()),
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("pdf-deps") => pdf_deps::run(&root()),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
|
||||
@@ -161,6 +164,7 @@ fn cmd_ci() -> Result<(), String> {
|
||||
run(c, "cargo fmt --all -- --check")
|
||||
}),
|
||||
),
|
||||
("pdf-deps", Box::new(|| pdf_deps::run(&root()))),
|
||||
(
|
||||
"clippy",
|
||||
Box::new(|| {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Check the immutable writer sources and keep PDF reading out of production exports.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const SHARED_REV: &str = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8";
|
||||
const REGISTRY: &str = "registry+https://github.com/rust-lang/crates.io-index";
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
|
||||
.current_dir(root)
|
||||
.args(["metadata", "--locked", "--format-version", "1"])
|
||||
.output()
|
||||
.map_err(|e| format!("cargo metadata: {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!("cargo metadata: {}", String::from_utf8_lossy(&output.stderr)));
|
||||
}
|
||||
let metadata: Value = serde_json::from_slice(&output.stdout).map_err(|e| format!("cargo metadata: {e}"))?;
|
||||
validate(&metadata)?;
|
||||
// Metadata merges target features even with --filter-platform. Check Cargo's
|
||||
// selected feature graph separately so native compression never enables threads on WASM.
|
||||
let output =
|
||||
Command::new(std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into())).arg("-vV").output().map_err(|e| format!("rustc host: {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!("rustc host: {}", String::from_utf8_lossy(&output.stderr)));
|
||||
}
|
||||
let version = String::from_utf8(output.stdout).map_err(|e| format!("rustc host: {e}"))?;
|
||||
let host = version.lines().find_map(|line| line.strip_prefix("host: ")).ok_or("rustc did not report its host target")?;
|
||||
for (target, parallel) in [(host, true), ("wasm32-unknown-unknown", false)] {
|
||||
let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
|
||||
.current_dir(root)
|
||||
.args([
|
||||
"tree",
|
||||
"--locked",
|
||||
"--target",
|
||||
target,
|
||||
"--edges",
|
||||
"normal,build",
|
||||
"--package",
|
||||
"deckcraft-pdf",
|
||||
"--depth",
|
||||
"2",
|
||||
"--no-dedupe",
|
||||
"--prefix",
|
||||
"none",
|
||||
"--format",
|
||||
"{p}|{f}",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("cargo tree ({target}): {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!("cargo tree ({target}): {}", String::from_utf8_lossy(&output.stderr)));
|
||||
}
|
||||
validate_target_features(&String::from_utf8_lossy(&output.stdout), parallel).map_err(|e| format!("{target}: {e}"))?;
|
||||
}
|
||||
println!("PDF writer: pinned craft-pdf/Krilla; native-only parallel compression; parser assertions are development-only");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate(metadata: &Value) -> Result<(), String> {
|
||||
let packages = metadata.get("packages").and_then(Value::as_array).ok_or("cargo metadata has no packages")?;
|
||||
let nodes = metadata.get("resolve").and_then(|r| r.get("nodes")).and_then(Value::as_array).ok_or("cargo metadata has no resolved nodes")?;
|
||||
let one_package = |name: &str| -> Result<&Value, String> {
|
||||
let matches: Vec<_> = packages.iter().filter(|p| p.get("name").and_then(Value::as_str) == Some(name)).collect();
|
||||
if matches.len() != 1 {
|
||||
return Err(format!("{name}: expected one resolved package, found {}", matches.len()));
|
||||
}
|
||||
matches.first().copied().ok_or_else(|| format!("missing {name}"))
|
||||
};
|
||||
let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}");
|
||||
for (name, version, expected_source, expected_features) in [
|
||||
("craft-pdf", "0.1.0", source.as_str(), vec!["write"]),
|
||||
("krilla", "0.8.2", source.as_str(), vec!["default", "raster-images", "rayon", "simple-text"]),
|
||||
("hayro-syntax", "0.7.2", REGISTRY, vec!["std"]),
|
||||
] {
|
||||
let package = one_package(name)?;
|
||||
if package.get("version").and_then(Value::as_str) != Some(version) || package.get("source").and_then(Value::as_str) != Some(expected_source) {
|
||||
return Err(format!("{name}: expected {version} from {expected_source}; got {package}"));
|
||||
}
|
||||
let id = package.get("id").and_then(Value::as_str).ok_or_else(|| format!("{name}: missing package id"))?;
|
||||
let node = nodes.iter().find(|n| n.get("id").and_then(Value::as_str) == Some(id)).ok_or_else(|| format!("{name}: no resolved node"))?;
|
||||
let features = node.get("features").and_then(Value::as_array).ok_or_else(|| format!("{name}: no resolved features"))?;
|
||||
let actual: BTreeSet<_> = features.iter().filter_map(Value::as_str).collect();
|
||||
let expected: BTreeSet<_> = expected_features.into_iter().collect();
|
||||
if actual != expected {
|
||||
return Err(format!("{name}: expected features {expected:?}; got {features:?}"));
|
||||
}
|
||||
}
|
||||
// Metadata includes development dependencies. Follow only normal/build edges from
|
||||
// the production exporter so a parser accidentally promoted into it cannot hide there.
|
||||
let by_id: HashMap<_, _> = packages.iter().filter_map(|p| p.get("id").and_then(Value::as_str).map(|id| (id, p))).collect();
|
||||
let root = one_package("deckcraft-pdf")?.get("id").and_then(Value::as_str).ok_or("deckcraft-pdf has no id")?;
|
||||
let mut pending = vec![root];
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(id) = pending.pop() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let package = by_id.get(id).ok_or_else(|| format!("unresolved package {id}"))?;
|
||||
let name = package.get("name").and_then(Value::as_str).ok_or("resolved package has no name")?;
|
||||
if name == "hayro" || name.starts_with("hayro-") {
|
||||
return Err(format!("unexpected production reader dependency {name}"));
|
||||
}
|
||||
let node = nodes.iter().find(|n| n.get("id").and_then(Value::as_str) == Some(id)).ok_or_else(|| format!("no resolved node for {id}"))?;
|
||||
for dep in node.get("deps").and_then(Value::as_array).ok_or_else(|| format!("no dependency edges for {id}"))? {
|
||||
let kinds = dep.get("dep_kinds").and_then(Value::as_array).ok_or("dependency has no kinds")?;
|
||||
if kinds.is_empty() {
|
||||
return Err("dependency has no kinds".into());
|
||||
}
|
||||
if kinds.iter().any(|k| k.get("kind").and_then(Value::as_str) != Some("dev")) {
|
||||
pending.push(dep.get("pkg").and_then(Value::as_str).ok_or("dependency has no package id")?);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_target_features(tree: &str, parallel: bool) -> Result<(), String> {
|
||||
let mut expected = BTreeSet::from(["default", "raster-images", "simple-text"]);
|
||||
if parallel {
|
||||
expected.insert("rayon");
|
||||
}
|
||||
let mut found = false;
|
||||
for (package, features) in tree.lines().filter_map(|line| line.split_once('|')).filter(|(package, _)| package.starts_with("krilla v")) {
|
||||
found = true;
|
||||
let actual: BTreeSet<_> = features.split(',').collect();
|
||||
if actual != expected {
|
||||
return Err(format!("{package}: expected features {expected:?}; got {actual:?}"));
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Err("missing Krilla in the exporter's target dependency graph".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn metadata() -> Value {
|
||||
let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}");
|
||||
json!({
|
||||
"packages": [
|
||||
{"id":"app", "name":"deckcraft-pdf"},
|
||||
{"id":"writer", "name":"craft-pdf", "version":"0.1.0", "source":source},
|
||||
{"id":"backend", "name":"krilla", "version":"0.8.2", "source":source},
|
||||
{"id":"parser", "name":"hayro-syntax", "version":"0.7.2", "source":REGISTRY}
|
||||
],
|
||||
"resolve":{"nodes":[
|
||||
{"id":"app", "deps":[{"pkg":"writer", "dep_kinds":[{"kind":null}]}, {"pkg":"parser", "dep_kinds":[{"kind":"dev"}]}]},
|
||||
{"id":"writer", "features":["write"], "deps":[{"pkg":"backend", "dep_kinds":[{"kind":null}]}]},
|
||||
{"id":"backend", "features":["default","raster-images","rayon","simple-text"], "deps":[]},
|
||||
{"id":"parser", "features":["std"], "deps":[]}
|
||||
]}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_compression_is_required_only_on_native_targets() {
|
||||
let serial = "krilla v0.8.2 (pinned)|default,raster-images,simple-text\n";
|
||||
let parallel = "krilla v0.8.2 (pinned)|default,raster-images,rayon,simple-text\n";
|
||||
assert!(validate_target_features(serial, false).is_ok());
|
||||
assert!(validate_target_features(parallel, true).is_ok());
|
||||
assert!(validate_target_features(serial, true).is_err());
|
||||
assert!(validate_target_features(parallel, false).is_err());
|
||||
assert!(validate_target_features("", false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unpinned_or_duplicate_writer_sources() {
|
||||
assert!(validate(&metadata()).is_ok());
|
||||
for source in [Value::Null, json!(REGISTRY), json!("git+https://github.com/storytold/craft-libs#other")] {
|
||||
let mut m = metadata();
|
||||
m["packages"][1]["source"] = source;
|
||||
assert!(validate(&m).is_err());
|
||||
}
|
||||
let mut m = metadata();
|
||||
let duplicate = m["packages"][2].clone();
|
||||
m["packages"].as_array_mut().unwrap().push(duplicate);
|
||||
assert!(validate(&m).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_import_features_and_missing_text_shaping() {
|
||||
for (node, features) in [
|
||||
(1, json!([])),
|
||||
(1, json!(["write", "write-pdf"])),
|
||||
(2, json!(["default", "raster-images", "rayon", "simple-text", "pdf"])),
|
||||
(2, json!(["raster-images"])),
|
||||
(3, json!(["std", "images"])),
|
||||
] {
|
||||
let mut m = metadata();
|
||||
m["resolve"]["nodes"][node]["features"] = features;
|
||||
assert!(validate(&m).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_dev_parser_promoted_to_production_directly_or_transitively() {
|
||||
for kind in [Value::Null, json!("build")] {
|
||||
let mut m = metadata();
|
||||
m["resolve"]["nodes"][0]["deps"][1]["dep_kinds"][0]["kind"] = kind;
|
||||
assert!(validate(&m).is_err());
|
||||
}
|
||||
let mut m = metadata();
|
||||
m["resolve"]["nodes"][2]["deps"] = json!([{"pkg":"parser", "dep_kinds":[{"kind":null}]}]);
|
||||
assert!(validate(&m).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_incomplete_dependency_metadata() {
|
||||
let mut m = metadata();
|
||||
m["resolve"]["nodes"][2]["deps"] = json!([{"pkg":"missing", "dep_kinds":[{"kind":null}]}]);
|
||||
assert!(validate(&m).is_err());
|
||||
let mut m = metadata();
|
||||
m["resolve"] = Value::Null;
|
||||
assert!(validate(&m).is_err());
|
||||
let mut m = metadata();
|
||||
m["packages"].as_array_mut().unwrap().retain(|p| p["name"] != "hayro-syntax");
|
||||
assert!(validate(&m).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user