mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-13 01:15:56 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac3fdf8b42 | ||
|
|
4c7e7446ab |
Generated
+24
-24
@@ -1038,7 +1038,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1060,7 +1060,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -1069,7 +1069,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1078,14 +1078,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
@@ -1095,14 +1095,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-anim",
|
||||
@@ -1128,7 +1128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -1140,7 +1140,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -1151,7 +1151,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1159,7 +1159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1168,7 +1168,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1177,7 +1177,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1187,7 +1187,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-audio",
|
||||
"craft-media",
|
||||
@@ -1203,7 +1203,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
@@ -1212,7 +1212,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
@@ -1224,7 +1224,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"deckcraft-fonts",
|
||||
@@ -1242,7 +1242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1258,7 +1258,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1277,7 +1277,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
@@ -1291,7 +1291,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -1317,7 +1317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1326,7 +1326,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
@@ -6311,7 +6311,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -85,20 +85,14 @@ impl AutomationRoots {
|
||||
|
||||
/// May the file or folder at `path` be read (or written)? `Err` says why not.
|
||||
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
|
||||
self.check_path(Path::new(path), access)
|
||||
}
|
||||
|
||||
/// [`Self::check`] for a path that may not be UTF-8: the checked path is the one opened.
|
||||
pub fn check_path(&self, path: &Path, access: Access) -> Result<(), String> {
|
||||
let path_text = path.display();
|
||||
let roots = match access {
|
||||
Access::Read => &self.read,
|
||||
Access::Write => &self.write,
|
||||
};
|
||||
if roots.is_empty() {
|
||||
return match access {
|
||||
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path_text}")),
|
||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path_text}")),
|
||||
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),
|
||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
|
||||
};
|
||||
}
|
||||
let resolved = resolve(path)?;
|
||||
@@ -107,8 +101,8 @@ impl AutomationRoots {
|
||||
}
|
||||
let roots = roots.iter().map(|root| root.shown.display().to_string()).collect::<Vec<_>>().join(", ");
|
||||
match access {
|
||||
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path_text}")),
|
||||
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path_text}")),
|
||||
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path}")),
|
||||
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,23 +191,17 @@ pub fn check_write(path: &str) -> Result<(), String> {
|
||||
current().map_or(Ok(()), |r| r.check(path, Access::Write))
|
||||
}
|
||||
|
||||
/// May `path` be written here? Always, without roots in force. For a path that may not be UTF-8.
|
||||
pub fn check_write_path(path: &Path) -> Result<(), String> {
|
||||
current().map_or(Ok(()), |r| r.check_path(path, Access::Write))
|
||||
}
|
||||
|
||||
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
|
||||
/// existing folder's links followed and the rest appended.
|
||||
fn resolve(path: &Path) -> Result<PathBuf, String> {
|
||||
if path.as_os_str().is_empty() {
|
||||
fn resolve(path: &str) -> Result<PathBuf, String> {
|
||||
if path.is_empty() {
|
||||
return Err("automation path rejected: path is empty".into());
|
||||
}
|
||||
let shown = path.display();
|
||||
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
|
||||
if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
|
||||
return Err(format!("automation path rejected: reserved device names are not allowed: {shown}"));
|
||||
if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
|
||||
return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));
|
||||
}
|
||||
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))?;
|
||||
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
|
||||
// The names below the nearest existing folder, last first.
|
||||
let mut missing: Vec<OsString> = vec![];
|
||||
let mut at = absolute.as_path();
|
||||
@@ -224,11 +212,11 @@ fn resolve(path: &Path) -> Result<PathBuf, String> {
|
||||
return Ok(found);
|
||||
}
|
||||
Err(e) => match std::fs::symlink_metadata(at) {
|
||||
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {shown}")),
|
||||
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")),
|
||||
// There, yet its real path can't be had (no permission): nothing to compare.
|
||||
Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
||||
Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
||||
Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
|
||||
},
|
||||
}
|
||||
let parent = at.parent();
|
||||
@@ -238,12 +226,12 @@ fn resolve(path: &Path) -> Result<PathBuf, String> {
|
||||
at = parent;
|
||||
}
|
||||
(Some(Component::ParentDir), _) => {
|
||||
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
|
||||
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}"));
|
||||
}
|
||||
// Down to the root (or drive) and it doesn't exist either.
|
||||
_ => {
|
||||
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
|
||||
return Err(format!("automation path rejected: cannot resolve {shown}: {e}"));
|
||||
return Err(format!("automation path rejected: cannot resolve {path}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,8 +699,6 @@ pub fn add_user_word(word: &str) -> std::io::Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
let path = user_dictionary_path().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no folder for the user dictionary"))?;
|
||||
// A confined automation client may write only inside its write roots, this file included.
|
||||
crate::file_access::check_write_path(&path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||
if let Some(d) = path.parent() {
|
||||
std::fs::create_dir_all(d)?;
|
||||
}
|
||||
@@ -1022,20 +1020,6 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
|
||||
assert_eq!(w, vec!["check", "done"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_confined_client_cannot_write_the_user_dictionary_outside_its_roots() {
|
||||
let root = std::env::temp_dir().join(format!("deckcraft-dict-root-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let roots = crate::file_access::AutomationRoots::new(std::slice::from_ref(&root), std::slice::from_ref(&root)).unwrap();
|
||||
// The user dictionary is a file of its own outside `root`, not whatever HOME points at.
|
||||
let outside = std::env::temp_dir().join(format!("deckcraft-dict-outside-{}", std::process::id()));
|
||||
set_user_dictionary_path(outside.join("user.dic"));
|
||||
let r = crate::file_access::confine(roots.as_ref(), || add_user_word("zqxconfinedword"));
|
||||
assert_eq!(r.map_err(|e| e.kind()), Err(std::io::ErrorKind::PermissionDenied));
|
||||
assert!(!outside.exists(), "the refused write created nothing");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_paragraph_with_a_huge_language_is_checked_in_linear_work() {
|
||||
use deckcraft_model::{Paragraph, Run, RunProps};
|
||||
|
||||
@@ -169,14 +169,8 @@ impl FontDb {
|
||||
if bundled.contains(fam) {
|
||||
continue;
|
||||
}
|
||||
if self.is_loaded(fam) || (self.system_fallback_enabled() && self.has_family(fam)) {
|
||||
// With system fallback off, only a face that is already loaded: `face` could load more catalog files.
|
||||
let f = if self.system_fallback_enabled() {
|
||||
self.face(fam, style)
|
||||
} else {
|
||||
let Some(f) = self.find(fam, style) else { continue };
|
||||
f
|
||||
};
|
||||
if self.is_loaded(fam) || self.has_family(fam) {
|
||||
let f = self.face(fam, style);
|
||||
if f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {
|
||||
return f;
|
||||
}
|
||||
@@ -317,21 +311,4 @@ mod tests {
|
||||
assert!(c.windows(2).all(|w| w[0] <= w[1]));
|
||||
}
|
||||
}
|
||||
|
||||
/// With system fallback off, the automatic cascades (script families and symbol faces) load no
|
||||
/// installed family: the result comes from what is already loaded.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn cascades_load_no_installed_family_when_system_fallback_is_off() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
let base = db.face(crate::DEFAULT_FAMILY, "Regular");
|
||||
let loaded = |db: &FontDb| [b"Arab", b"Hebr", b"Thai", b"Deva"].into_iter().flat_map(|s| os_families(*s)).filter(|f| db.is_loaded(f)).count();
|
||||
let before = loaded(&db);
|
||||
for c in ['\u{0628}', '\u{05D0}', '\u{0E01}', '\u{0905}', '\u{2211}', '\u{2603}'] {
|
||||
let f = db.cascade(&[c], std::slice::from_ref(&base), "Regular");
|
||||
assert!(!f.family.is_empty());
|
||||
}
|
||||
assert_eq!(loaded(&db), before, "no installed family was loaded");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -976,17 +976,6 @@ impl FontDb {
|
||||
let _ = on;
|
||||
}
|
||||
|
||||
/// Whether automatic system-font lookups (fallback by character, symbol and script families)
|
||||
/// may touch installed fonts. Lookups of a family the document or host names are not gated.
|
||||
pub(crate) fn system_fallback_enabled(&self) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
false
|
||||
}
|
||||
|
||||
/// Process-wide database: the bundled fonts, then the installed system fonts, cataloged the
|
||||
/// first time a lookup by family name needs them (or ahead of time by
|
||||
/// [`FontDb::scan_in_background`]).
|
||||
@@ -1298,7 +1287,7 @@ impl FontDb {
|
||||
Some(f)
|
||||
}
|
||||
|
||||
pub(crate) fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
|
||||
fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
|
||||
if style.contains('{') {
|
||||
{
|
||||
let faces = self.read_faces();
|
||||
@@ -1484,8 +1473,7 @@ impl FontDb {
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let cataloged: Vec<String> =
|
||||
if self.system_fallback_enabled() { self.read_catalog().iter().map(|e| e.family.clone()).collect() } else { Vec::new() };
|
||||
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
|
||||
for fam in SYSTEM_FALLBACKS.iter().filter(|f| f.contains("Symbol")) {
|
||||
if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) {
|
||||
self.load_cataloged(fam);
|
||||
|
||||
@@ -425,11 +425,3 @@ fn the_font_scan_skips_fifos_folders_and_link_loops() {
|
||||
assert!(fifo || n >= 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_system_fallback_gates_automatic_family_loads() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
assert!(cfg!(target_arch = "wasm32") || db.system_fallback_enabled());
|
||||
db.set_system_fallback(false);
|
||||
assert!(!db.system_fallback_enabled());
|
||||
}
|
||||
|
||||
@@ -16,12 +16,6 @@ const INVALID_REQUEST: i64 = -32600;
|
||||
const METHOD_NOT_FOUND: i64 = -32601;
|
||||
const INVALID_PARAMS: i64 = -32602;
|
||||
const INTERNAL_ERROR: i64 = -32603;
|
||||
|
||||
/// Entries one batch (a JSON-RPC batch, or the `batch` tool's commands) may hold.
|
||||
pub(crate) const MAX_BATCH_ITEMS: usize = 1000;
|
||||
/// Bytes of results one batch may gather before the rest of it is refused: each result can copy
|
||||
/// much of the document (an inspection holds every note), so a batch holds at most this much.
|
||||
pub(crate) const MAX_BATCH_BYTES: usize = 64 << 20;
|
||||
const RESOURCE_NOT_FOUND: i64 = -32002;
|
||||
/// Keep request frames in step with the connected backend's reply limit.
|
||||
const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
|
||||
@@ -99,28 +93,8 @@ impl Server {
|
||||
// Batches were removed in 2025-06-18; still answer older clients sensibly.
|
||||
if batch.is_empty() {
|
||||
Some(error(Value::Null, INVALID_REQUEST, "empty batch"))
|
||||
} else if batch.len() > MAX_BATCH_ITEMS {
|
||||
Some(error(Value::Null, INVALID_REQUEST, format!("a batch holds at most {MAX_BATCH_ITEMS} messages")))
|
||||
} else {
|
||||
let mut used = 0usize;
|
||||
let mut replies: Vec<Value> = Vec::new();
|
||||
for m in batch {
|
||||
if used > MAX_BATCH_BYTES {
|
||||
// Not run: answer each remaining request with a short error.
|
||||
if let Some(id) = m.get("id").filter(|id| id.is_string() || id.is_number()) {
|
||||
replies.push(error(id.clone(), INTERNAL_ERROR, "the batch's replies passed 64 MiB; not run"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = self.handle(m) {
|
||||
used = used.saturating_add(r.to_string().len());
|
||||
replies.push(if used > MAX_BATCH_BYTES {
|
||||
error(r.get("id").cloned().unwrap_or(Value::Null), INTERNAL_ERROR, "the batch's replies passed 64 MiB; reply dropped")
|
||||
} else {
|
||||
r
|
||||
});
|
||||
}
|
||||
}
|
||||
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
|
||||
(!replies.is_empty()).then_some(Value::Array(replies))
|
||||
}
|
||||
}
|
||||
|
||||
+10
-29
@@ -357,40 +357,21 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
|
||||
wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({}))))
|
||||
}
|
||||
"batch" => {
|
||||
use crate::server::{MAX_BATCH_BYTES, MAX_BATCH_ITEMS};
|
||||
let keep = a.get("keepGoing").and_then(Value::as_bool).unwrap_or(false);
|
||||
let commands = a.get("commands").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
if commands.len() > MAX_BATCH_ITEMS {
|
||||
return ToolResult::error(format!("a batch holds at most {MAX_BATCH_ITEMS} commands"));
|
||||
}
|
||||
let mut out = vec![];
|
||||
let mut used = 0usize;
|
||||
for c in commands {
|
||||
for c in a.get("commands").and_then(Value::as_array).cloned().unwrap_or_default() {
|
||||
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
|
||||
let (entry, failed) = match exec(b, id, c.get("params").cloned().unwrap_or(json!({}))) {
|
||||
Ok(v) => (json!({"command": id, "ok": true, "result": v}), false),
|
||||
Err(e) => (json!({"command": id, "ok": false, "error": e}), true),
|
||||
};
|
||||
// What the reply carries: the entry as text, escaped again inside the JSON-RPC reply.
|
||||
// Failures count too: with keepGoing, a remote backend's large errors add up.
|
||||
used = used.saturating_add(json!(entry.to_string()).to_string().len());
|
||||
if used > MAX_BATCH_BYTES {
|
||||
let shown: String = id.chars().take(128).collect();
|
||||
out.push(json!({"command": shown, "ok": false, "error": "the batch's results passed 64 MiB; stopped here"}));
|
||||
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
|
||||
}
|
||||
out.push(entry);
|
||||
if failed && !keep {
|
||||
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
|
||||
match exec(b, id, c.get("params").cloned().unwrap_or(json!({}))) {
|
||||
Ok(v) => out.push(json!({"command": id, "ok": true, "result": v})),
|
||||
Err(e) => {
|
||||
out.push(json!({"command": id, "ok": false, "error": e}));
|
||||
if !keep {
|
||||
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The reply is pretty-printed, which is longer than the compact entries counted above.
|
||||
let reply = ToolResult::json(&Value::Array(out));
|
||||
let text = reply.content[0]["text"].as_str().unwrap_or_default();
|
||||
if json!(text).to_string().len() > MAX_BATCH_BYTES {
|
||||
return ToolResult::error("the batch's results passed 64 MiB; nothing is returned");
|
||||
}
|
||||
reply
|
||||
ToolResult::json(&Value::Array(out))
|
||||
}
|
||||
"new_presentation" => wrap(exec(b, "file.new", a)),
|
||||
"open_presentation" => wrap(exec(b, "file.open", a)),
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
//! A batch (JSON-RPC or the `batch` tool) is bounded in entries and in the bytes of results it
|
||||
//! gathers, so a short request cannot copy a large document into memory hundreds of times.
|
||||
|
||||
use deckcraft_mcp::{Headless, Server};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn server_with_notes(bytes: usize) -> Server {
|
||||
let mut server = Server::new(Box::new(Headless::with_document()));
|
||||
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "slide.notes", "params": {"text": "n".repeat(bytes)}}}});
|
||||
let reply: Value = serde_json::from_str(&server.handle_line(&line.to_string()).unwrap()).unwrap();
|
||||
assert_ne!(reply["result"]["isError"], true, "{reply}");
|
||||
server
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_rpc_batches_are_bounded_in_entries_and_reply_bytes() {
|
||||
let mut server = server_with_notes(1 << 20);
|
||||
let ping = |i: usize| json!({"jsonrpc": "2.0", "id": i, "method": "ping"});
|
||||
let too_many: Vec<Value> = (0..1001).map(ping).collect();
|
||||
let reply: Value = serde_json::from_str(&server.handle_line(&Value::Array(too_many).to_string()).unwrap()).unwrap();
|
||||
assert_eq!(reply["error"]["code"], -32600, "{reply}");
|
||||
// 100 inspections of a slide with 1 MB of notes: about 64 are answered, the rest refused.
|
||||
let inspect = |i: usize| json!({"jsonrpc": "2.0", "id": i, "method": "tools/call", "params": {"name": "inspect_slide", "arguments": {}}});
|
||||
let line = Value::Array((0..100).map(inspect).collect()).to_string();
|
||||
let reply = server.handle_line(&line).unwrap();
|
||||
assert!(reply.len() < (64 << 20) + (4 << 20), "{} bytes", reply.len());
|
||||
let replies: Vec<Value> = serde_json::from_str(&reply).unwrap();
|
||||
assert_eq!(replies.len(), 100, "every request is answered");
|
||||
assert!(replies.last().unwrap()["error"]["message"].as_str().unwrap().contains("64 MiB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_batch_tool_is_bounded_in_commands_and_result_bytes() {
|
||||
let mut server = server_with_notes(1 << 20);
|
||||
let call = |server: &mut Server, commands: Vec<Value>| {
|
||||
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "batch", "arguments": {"commands": commands, "keepGoing": true}}});
|
||||
let reply: Value = serde_json::from_str(&server.handle_line(&line.to_string()).unwrap()).unwrap();
|
||||
(reply["result"]["isError"] == true, reply["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string())
|
||||
};
|
||||
let (err, text) = call(&mut server, vec![json!({"command": "document.inspect"}); 1001]);
|
||||
assert!(err && text.contains("at most 1000"), "{text}");
|
||||
let (err, text) = call(&mut server, vec![json!({"command": "slide.inspect"}); 100]);
|
||||
assert!(err && text.contains("64 MiB"), "{}", &text[text.len().saturating_sub(200)..]);
|
||||
assert!(text.len() < (64 << 20) + (4 << 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_batch_tool_charges_the_escaped_reply() {
|
||||
// Quotes escape twice on the way out (`\\\"`), so 30 results of ~2 MiB serialize to ~120 MiB.
|
||||
let mut server = Server::new(Box::new(Headless::with_document()));
|
||||
let notes = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "slide.notes", "params": {"text": "\"".repeat(1 << 20)}}}});
|
||||
server.handle_line(¬es.to_string()).unwrap();
|
||||
let commands = vec![json!({"command": "slide.inspect"}); 30];
|
||||
let line = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "batch", "arguments": {"commands": commands, "keepGoing": true}}});
|
||||
let reply = server.handle_line(&line.to_string()).unwrap();
|
||||
assert!(reply.len() < (64 << 20) + (4 << 20), "{} bytes", reply.len());
|
||||
assert!(reply.contains("64 MiB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_batch_tool_charges_failures_too() {
|
||||
// Each unknown command's error echoes its ~1 MiB id; with keepGoing none of them is a result.
|
||||
let mut server = Server::new(Box::new(Headless::with_document()));
|
||||
let id = "x".repeat(1 << 20);
|
||||
let commands = vec![json!({"command": id}); 70];
|
||||
let line = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "batch", "arguments": {"commands": commands, "keepGoing": true}}});
|
||||
let reply = server.handle_line(&line.to_string()).unwrap();
|
||||
assert!(reply.len() < (64 << 20) + (4 << 20), "{} bytes", reply.len());
|
||||
assert!(reply.contains("64 MiB"));
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod automation_roots;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod batch_budget;
|
||||
|
||||
@@ -392,9 +392,6 @@ pub struct Chart {
|
||||
/// Original chart XML part kept for round-trip of unmodelled options.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw: Option<String>,
|
||||
/// The chart was too large to model: `raw` and `kept` are written back unchanged on save.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub verbatim: bool,
|
||||
/// Package parts the original chart still points at (embedded workbook, styles, drawings).
|
||||
/// The PPTX writer clears this before comparing modelled state. Native saves keep it too.
|
||||
#[serde(skip_serializing_if = "ChartKept::is_empty")]
|
||||
@@ -488,7 +485,6 @@ impl Default for Chart {
|
||||
bin_count: None,
|
||||
bin_size: None,
|
||||
raw: None,
|
||||
verbatim: false,
|
||||
kept: ChartKept::default(),
|
||||
}
|
||||
}
|
||||
@@ -1315,7 +1311,6 @@ impl Chart {
|
||||
series: vec![],
|
||||
gap_width: 0.0,
|
||||
raw: None,
|
||||
verbatim: false,
|
||||
subtotals: vec![],
|
||||
..self.clone()
|
||||
};
|
||||
|
||||
+26
-37
@@ -301,14 +301,18 @@ pub fn master_text_pieces(pres: &Presentation, m: &deckcraft_model::Master, pape
|
||||
let (kx, ky) = (paper.0 / pres.notes_size.width.max(1.0), paper.1 / pres.notes_size.height.max(1.0));
|
||||
let mut out = vec![];
|
||||
for pt in deckcraft_render::place_master_texts(pres, m, n) {
|
||||
let mut para_chars: HashMap<usize, Vec<char>> = HashMap::new();
|
||||
for run in &pt.layout.runs {
|
||||
let Some(g) = run.glyphs.first() else { continue };
|
||||
let chars = para_chars.entry(run.para).or_insert_with(|| {
|
||||
pt.body.paragraphs.get(run.para).map(|p| p.runs.iter().flat_map(|r| r.content().chars()).collect()).unwrap_or_default()
|
||||
});
|
||||
let (a, b) = (run.chars.0.min(chars.len()), run.chars.1.min(chars.len()));
|
||||
let text: String = chars.get(a..b.max(a)).unwrap_or_default().iter().collect();
|
||||
let text: String = pt
|
||||
.body
|
||||
.paragraphs
|
||||
.get(run.para)
|
||||
.map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.skip(run.chars.0)
|
||||
.take(run.chars.1.saturating_sub(run.chars.0))
|
||||
.collect();
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -494,9 +498,6 @@ impl Backdrops {
|
||||
/// Most pixels one slide raster may have; it is RGBA, and the backdrop and the content are two rasters.
|
||||
const MAX_RASTER_PIXELS: f64 = 64_000_000.0;
|
||||
|
||||
/// Most ruled note lines one handout area gets (a letter page holds about 30).
|
||||
const MAX_NOTE_LINES: usize = 500;
|
||||
|
||||
/// Render scale for a slide of `slide` units drawn `box_width` points wide at `dpi`, with the total pixel count capped.
|
||||
fn raster_scale(box_width: f64, slide: (f64, f64), dpi: f64) -> f64 {
|
||||
let scale = (box_width / 72.0 * dpi.clamp(36.0, 600.0)) / slide.0;
|
||||
@@ -573,17 +574,20 @@ impl Exporter<'_> {
|
||||
|
||||
/// Invisible glyphs exactly over the rasterised text.
|
||||
fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) -> Result<()> {
|
||||
// Each paragraph's characters, gathered once: a long paragraph wraps into many runs.
|
||||
let mut para_chars: HashMap<usize, Vec<char>> = HashMap::new();
|
||||
for run in &pt.layout.runs {
|
||||
if run.glyphs.is_empty() || run.size <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let chars = para_chars.entry(run.para).or_insert_with(|| {
|
||||
pt.body.paragraphs.get(run.para).map(|p| p.runs.iter().flat_map(|r| r.content().chars()).collect()).unwrap_or_default()
|
||||
});
|
||||
let (a, b) = (run.chars.0.min(chars.len()), run.chars.1.min(chars.len()));
|
||||
let text: String = chars.get(a..b.max(a)).unwrap_or_default().iter().collect();
|
||||
let text: String = pt
|
||||
.body
|
||||
.paragraphs
|
||||
.get(run.para)
|
||||
.map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.skip(run.chars.0)
|
||||
.take(run.chars.1.saturating_sub(run.chars.0))
|
||||
.collect();
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -663,28 +667,19 @@ impl Exporter<'_> {
|
||||
let lh = size * 1.3;
|
||||
let mut y = area.y0 + size;
|
||||
s.set_fill(Some(grey(0x20, 1.0)));
|
||||
let space = width(" ");
|
||||
'outer: for para in text.split('\n') {
|
||||
// The line's width is kept as words are added, not measured again for each word.
|
||||
let mut line = String::new();
|
||||
let mut line_w = 0.0;
|
||||
for word in para.split(' ') {
|
||||
let word_w = width(word);
|
||||
let cand_w = if line.is_empty() { word_w } else { line_w + space + word_w };
|
||||
if cand_w > area.width() && !line.is_empty() {
|
||||
let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
|
||||
if width(&cand) > area.width() && !line.is_empty() {
|
||||
if y > area.y1 {
|
||||
break 'outer;
|
||||
}
|
||||
s.draw_text(Point::from_xy(area.x0 as f32, y as f32), font.clone(), size as f32, &line, false, TextDirection::Auto);
|
||||
y += lh;
|
||||
line = word.to_string();
|
||||
line_w = word_w;
|
||||
} else {
|
||||
if !line.is_empty() {
|
||||
line.push(' ');
|
||||
}
|
||||
line.push_str(word);
|
||||
line_w = cand_w;
|
||||
line = cand;
|
||||
}
|
||||
}
|
||||
if y > area.y1 {
|
||||
@@ -701,26 +696,20 @@ impl Exporter<'_> {
|
||||
|
||||
/// Ruled lines for handwritten notes (3-per-page handouts).
|
||||
fn note_lines(&self, s: &mut Surface, r: Rect) {
|
||||
// Counted, not stepped: at huge coordinates adding 22 leaves y unchanged, and a huge page
|
||||
// would want millions of rules.
|
||||
let first = r.y0 + 18.0;
|
||||
let count = ((r.y1 - first) / 22.0).floor() + 1.0;
|
||||
if count.is_nan() || count < 1.0 {
|
||||
return;
|
||||
}
|
||||
let mut y = r.y0 + 18.0;
|
||||
s.set_stroke(Some(krilla::paint::Stroke {
|
||||
paint: krilla::color::rgb::Color::new(0xB0, 0xB0, 0xB0).into(),
|
||||
width: 0.5,
|
||||
..Default::default()
|
||||
}));
|
||||
for i in 0..(count.min(MAX_NOTE_LINES as f64) as usize) {
|
||||
let y = first + 22.0 * i as f64;
|
||||
while y <= r.y1 {
|
||||
let mut p = kurbo::BezPath::new();
|
||||
p.move_to((r.x0, y));
|
||||
p.line_to((r.x1, y));
|
||||
if let Some(path) = to_path(&p) {
|
||||
s.draw_path(&path);
|
||||
}
|
||||
y += 22.0;
|
||||
}
|
||||
s.set_stroke(None);
|
||||
}
|
||||
|
||||
@@ -728,17 +728,3 @@ fn raster_scale_caps_total_pixels_not_each_side() {
|
||||
let s = raster_scale(720.0, (960.0, 540.0), 200.0);
|
||||
assert!((s - 720.0 / 72.0 * 200.0 / 960.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn huge_paper_and_long_notes_export_in_bounded_work() {
|
||||
// At 1e19 pt adding a rule's 22 pt leaves y unchanged: the rules are counted, not stepped. A narrow page keeps the raster small.
|
||||
let p = deck(1);
|
||||
let bytes = export(&p, &PdfOptions { paper: (612.0, 1.0e19), ..opts(PageLayout::Handouts { per_page: 3 }) }).expect("export");
|
||||
assert!(bytes.starts_with(b"%PDF-"));
|
||||
// 20,000 short words of notes, wrapped with the line width kept as words are added.
|
||||
let mut p = deck(1);
|
||||
Arc::make_mut(&mut p.slides[0]).notes = TextBody::from_text(&"ab ".repeat(20_000));
|
||||
let bytes = export(&p, &opts(PageLayout::Notes)).expect("export");
|
||||
assert!(bytes.starts_with(b"%PDF-"));
|
||||
}
|
||||
|
||||
@@ -70,13 +70,6 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
|
||||
read::import_checked(bytes)
|
||||
}
|
||||
|
||||
/// [`import`] with a lower presentation-wide chart point budget, so tests reach the over-budget
|
||||
/// path without a multi-million-point chart.
|
||||
#[doc(hidden)]
|
||||
pub fn import_with_chart_budget(bytes: &[u8], points: usize) -> Result<Presentation, PptxError> {
|
||||
read::import_with_chart_points(bytes, points).map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// Read the theme of a `.thmx` file, or of a `.pptx` / `.potx`'s first master, with its media.
|
||||
pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec<deckcraft_model::MediaItem>), PptxError> {
|
||||
read::import_theme(bytes)
|
||||
|
||||
@@ -38,17 +38,8 @@ pub struct Imp<'a> {
|
||||
pub vml_previews: HashMap<String, HashMap<String, String>>,
|
||||
/// Aggregate byte count of retained chart sidecars across the presentation.
|
||||
pub kept_sidecar_bytes: usize,
|
||||
/// Data slots of the charts decoded so far (see `chart::MAX_PRESENTATION_CHART_POINTS`).
|
||||
pub chart_points: usize,
|
||||
/// Points the presentation may hold across modelled charts.
|
||||
pub chart_points_cap: usize,
|
||||
/// Cached kept sidecars by normalized chart part name.
|
||||
pub kept_charts: HashMap<String, deckcraft_model::chart::ChartKept>,
|
||||
/// Chart parts whose sidecar capture was refused, with the retained-byte total it started from:
|
||||
/// the same start gives the same refusal, so the copy is not repeated.
|
||||
pub rejected_kept: HashMap<String, usize>,
|
||||
/// Bytes of chart parts decoded so far, rejected ones included (see `chart::MAX_CHART_DECODE_BYTES`).
|
||||
pub chart_decode_bytes: usize,
|
||||
/// Add-in tags parts, parsed once each, by normalized part name.
|
||||
pub tags_by_part: HashMap<String, Arc<[(String, String)]>>,
|
||||
/// Bytes of tags copied into the model so far (see [`MAX_TAG_BYTES`]).
|
||||
@@ -454,11 +445,7 @@ pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec<
|
||||
table_style_ids: Vec::new(),
|
||||
vml_previews: HashMap::new(),
|
||||
kept_sidecar_bytes: 0,
|
||||
chart_points: 0,
|
||||
chart_points_cap: chart::MAX_PRESENTATION_CHART_POINTS,
|
||||
kept_charts: HashMap::new(),
|
||||
rejected_kept: HashMap::new(),
|
||||
chart_decode_bytes: 0,
|
||||
tags_by_part: HashMap::new(),
|
||||
tag_bytes: 0,
|
||||
tags_capped: false,
|
||||
@@ -476,11 +463,6 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
|
||||
/// [`import`], plus the parts of the slide list the file names but that couldn't be read (a
|
||||
/// missing or truncated slide): those slides are left out, so a caller can say so (#203).
|
||||
pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxError> {
|
||||
import_with_chart_points(bytes, chart::MAX_PRESENTATION_CHART_POINTS)
|
||||
}
|
||||
|
||||
/// [`import_checked`] with the presentation's chart point budget set (tests lower it).
|
||||
pub(crate) fn import_with_chart_points(bytes: &[u8], chart_points_cap: usize) -> Result<(Presentation, Vec<String>), PptxError> {
|
||||
let mut lost_slides: Vec<String> = vec![];
|
||||
let pkg = Package::open(bytes)?;
|
||||
let root_rels = pkg.rels("");
|
||||
@@ -505,11 +487,7 @@ pub(crate) fn import_with_chart_points(bytes: &[u8], chart_points_cap: usize) ->
|
||||
table_style_ids: Vec::new(),
|
||||
vml_previews: HashMap::new(),
|
||||
kept_sidecar_bytes: 0,
|
||||
chart_points: 0,
|
||||
chart_points_cap,
|
||||
kept_charts: HashMap::new(),
|
||||
rejected_kept: HashMap::new(),
|
||||
chart_decode_bytes: 0,
|
||||
tags_by_part: HashMap::new(),
|
||||
tag_bytes: 0,
|
||||
tags_capped: false,
|
||||
|
||||
@@ -39,33 +39,6 @@ pub(crate) fn rich_text(t: &El) -> Option<String> {
|
||||
/// Most category/value/size slots one chart may decode across all its series and caches, so
|
||||
/// sparse `ptCount`/`idx` declarations can't expand far beyond the XML they came from.
|
||||
pub(crate) const MAX_CHART_POINTS: usize = 1_000_000;
|
||||
/// Data slots (values and categories) the charts of one presentation may hold in all; a chart past
|
||||
/// it is read as a placeholder frame. Each frame that references a chart decodes its own copy.
|
||||
pub(crate) const MAX_PRESENTATION_CHART_POINTS: usize = 4_000_000;
|
||||
|
||||
/// Chart part bytes all chart frames of a presentation may decode, counting every attempt: a
|
||||
/// chart that is rejected, or has no data slots, would otherwise be decoded again for each frame
|
||||
/// that shows it.
|
||||
pub(crate) const MAX_CHART_DECODE_BYTES: usize = 256 * 1024 * 1024;
|
||||
|
||||
/// Slots a decoded chart holds, counting the chart itself as at least one.
|
||||
pub(crate) fn point_count(c: &Chart) -> usize {
|
||||
let series = |s: &deckcraft_model::chart::Series| {
|
||||
let bars = s.error_bars.as_ref().map_or(0, |e| e.plus.len().saturating_add(e.minus.len()));
|
||||
s.values.len().saturating_add(s.x_values.len()).saturating_add(s.sizes.len()).saturating_add(bars)
|
||||
};
|
||||
let levels: usize = c.category_levels.iter().map(Vec::len).sum();
|
||||
1usize.saturating_add(c.categories.len()).saturating_add(levels).saturating_add(c.series.iter().map(series).fold(0, usize::saturating_add))
|
||||
}
|
||||
|
||||
/// Bytes of the strings a kept relationship owns.
|
||||
fn part_bytes(p: &deckcraft_model::chart::ChartPart) -> usize {
|
||||
p.bytes.len() + p.name.len() + p.content_type.len()
|
||||
}
|
||||
|
||||
fn rel_bytes(r: &deckcraft_model::chart::ChartRel) -> usize {
|
||||
r.id.len() + r.rel_type.len() + r.target.len() + r.part.len()
|
||||
}
|
||||
|
||||
/// Most slots one cache may hold.
|
||||
const MAX_CACHE_POINTS: usize = 100_000;
|
||||
@@ -130,48 +103,6 @@ pub fn read_chart_xml(bytes: &[u8]) -> Option<Chart> {
|
||||
Some(c)
|
||||
}
|
||||
|
||||
/// The chart a graphic frame points at. A chart that would cross the presentation's point or
|
||||
/// decode limits is not modelled: it is kept as its original part with the relationships it
|
||||
/// reaches (`verbatim`), so a save writes it back whole instead of dropping it and its workbook.
|
||||
pub(crate) fn frame_chart(imp: &mut super::Imp, part: &super::Part, rid: &str, uri: &str) -> Option<Chart> {
|
||||
let rel = part.rels.get(rid)?;
|
||||
let chartex = uri.ends_with("/chartex");
|
||||
let bytes = imp.pkg.xml_bytes(&rel.target)?;
|
||||
if imp.chart_decode_bytes.saturating_add(bytes.len()) > MAX_CHART_DECODE_BYTES {
|
||||
log::warn!("pptx: chart {} is past the presentation's chart decode limit and is not kept", rel.target);
|
||||
return None;
|
||||
}
|
||||
imp.chart_decode_bytes += bytes.len();
|
||||
let parsed = if imp.chart_points < imp.chart_points_cap {
|
||||
let c = if chartex { super::read_chartex_xml(bytes) } else { super::read_chart_xml(bytes) }?;
|
||||
let n = point_count(&c);
|
||||
if imp.chart_points.saturating_add(n) <= imp.chart_points_cap {
|
||||
imp.chart_points = imp.chart_points.saturating_add(n);
|
||||
let mut c = c;
|
||||
c.kept = capture_kept(imp, &rel.target);
|
||||
return Some(c);
|
||||
}
|
||||
Some(c)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Past the limit: keep the part itself (charged to the sidecar budget) and show an empty chart.
|
||||
if chartex || imp.kept_sidecar_bytes.saturating_add(bytes.len()) > MAX_KEPT_TOTAL_BYTES {
|
||||
log::warn!("pptx: chart {} is past the presentation's chart limits and is not kept", rel.target);
|
||||
return None;
|
||||
}
|
||||
imp.kept_sidecar_bytes += bytes.len();
|
||||
log::warn!("pptx: chart {} is past the presentation's chart limits; shown empty and written back unchanged", rel.target);
|
||||
let mut c = parsed.unwrap_or_default();
|
||||
c.series.clear();
|
||||
c.categories.clear();
|
||||
c.category_levels.clear();
|
||||
c.raw = Some(String::from_utf8_lossy(bytes).into_owned());
|
||||
c.verbatim = true;
|
||||
c.kept = capture_kept(imp, &rel.target);
|
||||
Some(c)
|
||||
}
|
||||
|
||||
pub(crate) fn chart_from(root: &El) -> Option<Chart> {
|
||||
chart_within(root, MAX_CHART_POINTS)
|
||||
}
|
||||
@@ -535,20 +466,9 @@ pub(crate) fn capture_kept(imp: &mut super::Imp, chart_part: &str) -> deckcraft_
|
||||
|
||||
let key = crate::opc::norm(chart_part);
|
||||
if let Some(cached) = imp.kept_charts.get(&key) {
|
||||
// Every frame that shows this chart gets its own copy, so each copy is charged like the first.
|
||||
let bytes: usize = cached.parts.iter().map(part_bytes).sum::<usize>() + cached.rels.iter().map(rel_bytes).sum::<usize>();
|
||||
if imp.kept_sidecar_bytes.saturating_add(bytes) > MAX_KEPT_TOTAL_BYTES {
|
||||
log::warn!("pptx: chart sidecar retained byte budget exceeded for {chart_part}");
|
||||
return ChartKept::default();
|
||||
}
|
||||
imp.kept_sidecar_bytes += bytes;
|
||||
return cached.clone();
|
||||
}
|
||||
|
||||
let charged_before = imp.kept_sidecar_bytes;
|
||||
if imp.rejected_kept.get(&key) == Some(&charged_before) {
|
||||
return ChartKept::default();
|
||||
}
|
||||
let mut kept = ChartKept::default();
|
||||
// The chart document itself is rewritten; don't store a second copy of it.
|
||||
let mut seen = std::collections::HashMap::from([(key.clone(), true)]);
|
||||
@@ -565,18 +485,6 @@ pub(crate) fn capture_kept(imp: &mut super::Imp, chart_part: &str) -> deckcraft_
|
||||
kept.rels.push(ChartRel { id: r.id, rel_type: r.ty, target: r.target_raw, part: r.target, external: false });
|
||||
}
|
||||
}
|
||||
// The first capture's relationship strings and the parts' names and content types are retained
|
||||
// too (the part bytes were charged as copied).
|
||||
let rel_total: usize =
|
||||
kept.rels.iter().map(rel_bytes).sum::<usize>() + kept.parts.iter().map(|p| p.name.len() + p.content_type.len()).sum::<usize>();
|
||||
if imp.kept_sidecar_bytes.saturating_add(rel_total) > MAX_KEPT_TOTAL_BYTES {
|
||||
log::warn!("pptx: chart sidecar retained byte budget exceeded for {chart_part}");
|
||||
// The dropped parts must not stay charged against later charts.
|
||||
imp.kept_sidecar_bytes = charged_before;
|
||||
imp.rejected_kept.insert(key, charged_before);
|
||||
return ChartKept::default();
|
||||
}
|
||||
imp.kept_sidecar_bytes += rel_total;
|
||||
imp.kept_charts.insert(key, kept.clone());
|
||||
kept
|
||||
}
|
||||
@@ -682,16 +590,6 @@ fn text_size(e: Option<&El>) -> Option<f64> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn point_count_charges_every_retained_vector() {
|
||||
let mut c = Chart { categories: vec!["a".into()], category_levels: vec![vec!["x".into(); 2], vec!["y".into(); 3]], ..Default::default() };
|
||||
let mut s =
|
||||
deckcraft_model::chart::Series { values: vec![Some(1.0); 4], x_values: vec![None; 5], sizes: vec![None; 6], ..Default::default() };
|
||||
s.error_bars = Some(deckcraft_model::chart::ErrorBars { plus: vec![None; 7], minus: vec![None; 8], ..Default::default() });
|
||||
c.series = vec![s];
|
||||
assert_eq!(point_count(&c), 1 + 1 + 5 + 4 + 5 + 6 + 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_caches_share_one_chart_wide_budget() {
|
||||
// Every series declares 100000 points but writes one: without a chart-wide budget each
|
||||
|
||||
@@ -434,8 +434,11 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
|
||||
return s;
|
||||
}
|
||||
if let Some(c) = gd.child("chart")
|
||||
&& let Some(chart) = super::chart::frame_chart(imp, part, c.attr("r:id").unwrap_or(""), uri)
|
||||
&& let Some(rel) = part.rels.get(c.attr("r:id").unwrap_or(""))
|
||||
&& let Some(bytes) = imp.pkg.xml_bytes(&rel.target)
|
||||
&& let Some(mut chart) = if uri.ends_with("/chartex") { super::read_chartex_xml(bytes) } else { super::read_chart_xml(bytes) }
|
||||
{
|
||||
chart.kept = super::chart::capture_kept(imp, &rel.target);
|
||||
s.kind = ShapeKind::Chart(Box::new(chart));
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -610,18 +610,6 @@ fn kept_chartex(c: &Chart) -> Option<Vec<u8>> {
|
||||
|
||||
/// The chart part XML, and whether it is the original part (so its relationships still apply).
|
||||
pub fn chart_xml(c: &Chart) -> (Vec<u8>, bool) {
|
||||
// A chart too large to model is written back as it was read.
|
||||
if c.verbatim
|
||||
&& let Some(raw) = &c.raw
|
||||
&& let Ok(doc) = crate::xml::parse(raw.as_bytes())
|
||||
&& doc.root.is("chartSpace")
|
||||
{
|
||||
let mut keep = doc.root;
|
||||
drop_unkept_refs(&mut keep, c);
|
||||
let mut w = W::new();
|
||||
w.raw(&keep.to_xml());
|
||||
return (w.finish(), true);
|
||||
}
|
||||
// Reuse the kept part when the chart is unchanged since it was read.
|
||||
if let Some(raw) = &c.raw
|
||||
&& !raw.contains(NS_CX)
|
||||
|
||||
@@ -328,30 +328,3 @@ fn an_unchanged_chartex_keeps_its_embedded_workbook() {
|
||||
assert!(saved.iter().all(|(n, _)| n != "ppt/charts/_rels/chart1.xml.rels"), "the fallback chart has no workbook");
|
||||
assert!(!text_of(&saved, "ppt/charts/chart1.xml").contains("externalData"));
|
||||
}
|
||||
|
||||
/// A chart past the presentation's point budget is not modelled, but a save writes the original
|
||||
/// chart part and its workbook back instead of dropping both.
|
||||
#[test]
|
||||
fn a_chart_past_the_point_budget_is_saved_whole() {
|
||||
let src = with_workbook(&chart_pptx());
|
||||
let p = deckcraft_pptx::import_with_chart_budget(&src, 0).expect("import");
|
||||
let chart = p
|
||||
.slides
|
||||
.iter()
|
||||
.flat_map(|s| s.shapes.iter())
|
||||
.find_map(|s| match &s.kind {
|
||||
ShapeKind::Chart(c) => Some(c.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the chart stays a chart");
|
||||
assert!(chart.verbatim && chart.series.is_empty());
|
||||
let saved = parts(&deckcraft_pptx::export(&p).expect("export"));
|
||||
let workbook = saved.iter().find(|(n, _)| n == "ppt/embeddings/kept1.xlsx").map(|(_, d)| d.as_slice());
|
||||
assert_eq!(workbook, Some(WORKBOOK));
|
||||
let xml = saved
|
||||
.iter()
|
||||
.find(|(n, _)| n.starts_with("ppt/charts/chart") && n.ends_with(".xml"))
|
||||
.map(|(_, d)| String::from_utf8_lossy(d).into_owned())
|
||||
.expect("chart xml");
|
||||
assert!(xml.contains("externalData") && xml.contains("c:pie"), "{xml}");
|
||||
}
|
||||
|
||||
@@ -125,13 +125,10 @@ pub fn draw(ctx: &mut RenderContext, bevel: &Bevel, fill: Option<Rgba>, path: &B
|
||||
let longest = bounds.width().max(bounds.height()) * k;
|
||||
let s = (MAX_SIDE / longest).min(1.0) * k;
|
||||
let (pw, ph) = (((bounds.width() * s).ceil() as u32).clamp(1, 2048), ((bounds.height() * s).ceil() as u32).clamp(1, 2048));
|
||||
// Each pixel tests every edge, so a dense outline is simplified to the raster's edge budget.
|
||||
let pixel = (bounds.width() / f64::from(pw)).min(bounds.height() / f64::from(ph));
|
||||
let polys = crate::pathgrad::simplify(crate::pathgrad::polygons(path), pixel, crate::pathgrad::edge_budget(pw, ph));
|
||||
let polys = crate::pathgrad::polygons(path);
|
||||
if polys.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (pw, ph) = crate::pathgrad::fit_raster(polys.iter().map(|p| p.len().saturating_sub(1)).sum(), pw, ph);
|
||||
let areas: Vec<f64> = polys.iter().map(|p| p.windows(2).map(|w| w[0].x * w[1].y - w[1].x * w[0].y).sum::<f64>() / 2.0).collect();
|
||||
let face = lit.factor([0.0, 0.0, 1.0]);
|
||||
let ratio = if bevel.w > 0.0 { bevel.h / bevel.w } else { 0.0 };
|
||||
|
||||
@@ -11,8 +11,6 @@ use vello_cpu::{RenderContext, peniko};
|
||||
|
||||
/// Nesting depth of clips and layers past which a glyph opens no more (hostile fonts).
|
||||
const MAX_DEPTH: usize = 64;
|
||||
/// Fills and gradient stops one colour glyph may paint; the rest of a hostile glyph is skipped.
|
||||
const MAX_WORK: usize = 100_000;
|
||||
|
||||
/// Paint `gid` of `face` as a colour glyph, its origin mapped by `at` (y down, points) at `scale`
|
||||
/// points per font unit. `fg` is the text colour (palette index 0xFFFF) and `alpha` fades it all.
|
||||
@@ -28,7 +26,7 @@ pub fn paint(ctx: &mut RenderContext, face: &FontFace, gid: u32, at: Affine, sca
|
||||
.unwrap_or_default();
|
||||
// Font units are y up; the slide is y down.
|
||||
let base = at * Affine::scale(scale) * Affine::scale_non_uniform(1.0, -1.0);
|
||||
let mut p = Painter { ctx, face, base, stack: vec![Affine::IDENTITY], palette, fg, opened: Vec::new(), work: 0 };
|
||||
let mut p = Painter { ctx, face, base, stack: vec![Affine::IDENTITY], palette, fg, opened: Vec::new() };
|
||||
p.ctx.push_layer(None, None, Some(alpha.clamp(0.0, 1.0) as f32), None, None);
|
||||
let ok = glyph.paint(face.location(), &mut p).is_ok();
|
||||
// A malformed glyph may leave layers open: close them all.
|
||||
@@ -52,8 +50,6 @@ struct Painter<'a> {
|
||||
/// Clips and composite groups pushed by the glyph, innermost last: true when a layer was
|
||||
/// opened on the context for it (false past `MAX_DEPTH`, so its pop is skipped too).
|
||||
opened: Vec<bool>,
|
||||
/// Fills plus gradient stops painted so far, against [`MAX_WORK`].
|
||||
work: usize,
|
||||
}
|
||||
|
||||
fn affine(t: &Transform) -> Affine {
|
||||
@@ -178,16 +174,6 @@ impl ColorPainter for Painter<'_> {
|
||||
}
|
||||
|
||||
fn fill(&mut self, brush: Brush<'_>) {
|
||||
let stops = match &brush {
|
||||
Brush::Solid { .. } => 0,
|
||||
Brush::LinearGradient { color_stops, .. } | Brush::RadialGradient { color_stops, .. } | Brush::SweepGradient { color_stops, .. } => {
|
||||
color_stops.len()
|
||||
}
|
||||
};
|
||||
self.work = self.work.saturating_add(1 + stops);
|
||||
if self.work > MAX_WORK {
|
||||
return;
|
||||
}
|
||||
match brush {
|
||||
Brush::Solid { palette_index, alpha } => {
|
||||
let c = self.color(palette_index, alpha);
|
||||
|
||||
+28
-140
@@ -25,39 +25,6 @@ const MAX_EDGE_WORK: u64 = 64_000_000;
|
||||
/// Fewest edges a simplified polygon keeps, so a coarse outline still has a shape.
|
||||
const MIN_POLY_EDGES: usize = 8;
|
||||
|
||||
/// Most points `polygons` keeps from one path; a path flattening past this (a huge finite curve) is
|
||||
/// not drawn rather than held in memory first.
|
||||
const MAX_POINTS: usize = 2_000_000;
|
||||
|
||||
/// Quads beyond one per cubic that a path's cubics may subdivide into before [`polygons`] refuses it.
|
||||
const MAX_EXTRA_QUADS: f64 = 200_000.0;
|
||||
|
||||
/// Flattening tolerance of [`polygons`], in path units.
|
||||
const FLATTEN_TOLERANCE: f64 = 0.25;
|
||||
|
||||
/// The edge budget for a `pw` x `ph` raster where each pixel tests every edge.
|
||||
pub(crate) fn edge_budget(pw: u32, ph: u32) -> usize {
|
||||
let pixels = u64::from(pw).saturating_mul(u64::from(ph)).max(1);
|
||||
usize::try_from(MAX_EDGE_WORK / pixels).unwrap_or(usize::MAX).max(MIN_POLY_EDGES)
|
||||
}
|
||||
|
||||
/// `pw` x `ph` cut down (aspect kept) until every pixel testing all of `edges` stays within
|
||||
/// [`MAX_EDGE_WORK`]; the raster is stretched when painted, so a many-contour outline keeps every
|
||||
/// contour at a coarser sampling.
|
||||
pub(crate) fn fit_raster(edges: usize, pw: u32, ph: u32) -> (u32, u32) {
|
||||
let work = u64::from(pw).saturating_mul(u64::from(ph)).saturating_mul(edges.max(1) as u64);
|
||||
if work <= MAX_EDGE_WORK {
|
||||
return (pw, ph);
|
||||
}
|
||||
let k = (MAX_EDGE_WORK as f64 / work as f64).sqrt();
|
||||
let w = ((f64::from(pw) * k) as u32).max(1);
|
||||
let h = ((f64::from(ph) * k) as u32).max(1);
|
||||
// A side held at 1 pixel leaves the other to take the rest of the cut.
|
||||
let room = |side: u32| u32::try_from(MAX_EDGE_WORK / u64::from(side).saturating_mul(edges.max(1) as u64).max(1)).unwrap_or(u32::MAX).max(1);
|
||||
let w = w.min(room(h));
|
||||
(w, h.min(room(w)))
|
||||
}
|
||||
|
||||
/// Colour position (0 at the focus, 1 at the edge) of `p` for path `kind` in `bounds` with the
|
||||
/// focus rect `focus` (fractions inset from left, top, right, bottom), `outline` being the
|
||||
/// shape's flattened polygon(s) for `shape`.
|
||||
@@ -145,59 +112,23 @@ fn shape_position(outline: &[Vec<Point>], c: Point, p: Point) -> f64 {
|
||||
|
||||
/// The outline flattened into closed polygons.
|
||||
pub fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
|
||||
// `kurbo::flatten` buffers all of a cubic's quads before calling back, and cannot be stopped
|
||||
// once running, so bound the quads the cubics will make first (kurbo's own count: it splits
|
||||
// cubics into quads at a tenth of the tolerance). An ordinary curve makes a few; only those
|
||||
// past the first of each count.
|
||||
let mut extra_quads = 0.0f64;
|
||||
let mut at = Point::ZERO;
|
||||
let mut start = Point::ZERO;
|
||||
for el in path.iter() {
|
||||
match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
at = p;
|
||||
start = p;
|
||||
}
|
||||
PathEl::LineTo(p) | PathEl::QuadTo(_, p) => at = p,
|
||||
PathEl::CurveTo(a, b, p) => {
|
||||
let err = ((b.to_vec2() * 3.0 - p.to_vec2()) - (a.to_vec2() * 3.0 - at.to_vec2())).hypot2();
|
||||
extra_quads += (err / (432.0 * (FLATTEN_TOLERANCE * 0.1).powi(2))).powf(1.0 / 6.0).ceil().max(1.0) - 1.0;
|
||||
// A non-finite estimate is over the limit too.
|
||||
if extra_quads.is_nan() || extra_quads > MAX_EXTRA_QUADS {
|
||||
return Vec::new();
|
||||
}
|
||||
at = p;
|
||||
}
|
||||
PathEl::ClosePath => at = start,
|
||||
}
|
||||
}
|
||||
let mut out: Vec<Vec<Point>> = Vec::new();
|
||||
let mut cur: Vec<Point> = Vec::new();
|
||||
let mut total = 0usize;
|
||||
kurbo::flatten(path.iter(), FLATTEN_TOLERANCE, |el| {
|
||||
total += 1;
|
||||
if total > MAX_POINTS {
|
||||
return;
|
||||
}
|
||||
match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
if cur.len() > 1 {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
cur = vec![p];
|
||||
kurbo::flatten(path.iter(), 0.25, |el| match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
if cur.len() > 1 {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
PathEl::LineTo(p) => cur.push(p),
|
||||
PathEl::ClosePath => {
|
||||
if let Some(f) = cur.first().copied() {
|
||||
cur.push(f);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
cur = vec![p];
|
||||
}
|
||||
PathEl::LineTo(p) => cur.push(p),
|
||||
PathEl::ClosePath => {
|
||||
if let Some(f) = cur.first().copied() {
|
||||
cur.push(f);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
if total > MAX_POINTS {
|
||||
return Vec::new();
|
||||
}
|
||||
if cur.len() > 1 {
|
||||
out.push(cur);
|
||||
}
|
||||
@@ -214,9 +145,8 @@ pub fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
|
||||
|
||||
/// `outline` cut down to at most about `max_edges` edges in total: first by dropping points
|
||||
/// closer than `tol` to the last kept one (detail finer than a raster pixel), then, if still too
|
||||
/// many, by keeping every k-th point (a polygon too small to thin stays whole, and none is
|
||||
/// dropped). Each polygon stays closed. Returns it unchanged when it fits.
|
||||
pub(crate) fn simplify(outline: Vec<Vec<Point>>, tol: f64, max_edges: usize) -> Vec<Vec<Point>> {
|
||||
/// many, by keeping every k-th point. Each polygon stays closed. Returns it unchanged when it fits.
|
||||
fn simplify(outline: Vec<Vec<Point>>, tol: f64, max_edges: usize) -> Vec<Vec<Point>> {
|
||||
let edges = |o: &[Vec<Point>]| o.iter().map(|p| p.len().saturating_sub(1)).sum::<usize>();
|
||||
if edges(&outline) <= max_edges {
|
||||
return outline;
|
||||
@@ -259,8 +189,15 @@ pub(crate) fn simplify(outline: Vec<Vec<Point>>, tol: f64, max_edges: usize) ->
|
||||
}
|
||||
*poly = kept;
|
||||
}
|
||||
// Polygons too small to thin stay whole, so the total can still exceed `max_edges`: the
|
||||
// caller shrinks its raster ([`fit_raster`]) rather than lose contours.
|
||||
// Polygons too small to thin stay whole; drop the smallest ones if they alone overflow.
|
||||
if edges(&out) > max_edges {
|
||||
out.sort_by_key(|p| std::cmp::Reverse(p.len()));
|
||||
let mut sum = 0usize;
|
||||
out.retain(|p| {
|
||||
sum = sum.saturating_add(p.len().saturating_sub(1));
|
||||
sum <= max_edges
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -295,20 +232,14 @@ pub fn fill(ctx: &mut RenderContext, kind: &str, focus: [f64; 4], stops: &[(f64,
|
||||
let longest = bounds.width().max(bounds.height()) * k;
|
||||
let s = (MAX_SIDE / longest).min(1.0) * k;
|
||||
let (pw, ph) = (((bounds.width() * s).ceil() as u32).clamp(1, 4096), ((bounds.height() * s).ceil() as u32).clamp(1, 4096));
|
||||
let (outline, pw, ph) = if kind == "shape" {
|
||||
let max_edges = edge_budget(pw, ph);
|
||||
let outline = if kind == "shape" {
|
||||
let pixels = u64::from(pw).saturating_mul(u64::from(ph)).max(1);
|
||||
let max_edges = usize::try_from(MAX_EDGE_WORK / pixels).unwrap_or(usize::MAX).max(MIN_POLY_EDGES);
|
||||
let pixel = (bounds.width() / f64::from(pw)).min(bounds.height() / f64::from(ph));
|
||||
let o = simplify(polygons(path), pixel, max_edges);
|
||||
let (pw, ph) = fit_raster(o.iter().map(|p| p.len().saturating_sub(1)).sum(), pw, ph);
|
||||
(o, pw, ph)
|
||||
simplify(polygons(path), pixel, max_edges)
|
||||
} else {
|
||||
(Vec::new(), pw, ph)
|
||||
Vec::new()
|
||||
};
|
||||
// An outline `polygons` refused (too many points) has no contours: skip the shape rather than
|
||||
// paint it with the rectangle fallback's different gradient.
|
||||
if kind == "shape" && outline.is_empty() {
|
||||
return;
|
||||
}
|
||||
let lut = ramp(stops);
|
||||
let a = alpha.clamp(0.0, 1.0);
|
||||
let mut data = Vec::with_capacity((pw * ph) as usize);
|
||||
@@ -441,20 +372,6 @@ mod tests {
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_gradient_with_no_usable_outline_paints_nothing() {
|
||||
// One polygon over the per-polygon cap is dropped by `polygons`.
|
||||
let path = many_segment_square(26_000);
|
||||
assert!(polygons(&path).is_empty());
|
||||
let mut ctx = RenderContext::new(64, 64);
|
||||
let stops = [(0.0, Rgba::rgb(0, 0, 0)), (1.0, Rgba::rgb(255, 255, 255))];
|
||||
fill(&mut ctx, "shape", CENTRE, &stops, Rect::new(0.0, 0.0, 64.0, 64.0), &path, Affine::IDENTITY, 1.0);
|
||||
ctx.flush();
|
||||
let mut px = vec![0u8; 64 * 64 * 4];
|
||||
ctx.render(vello_cpu::PixmapMut::new(64, 64, &mut px).unwrap(), &mut vello_cpu::Resources::new());
|
||||
assert!(px.chunks(4).all(|p| p[3] == 0), "nothing was painted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ramp_runs_through_the_stops() {
|
||||
let stops = [(0.0, Rgba::rgb(0, 0, 0)), (0.5, Rgba::rgb(128, 128, 128)), (1.0, Rgba::rgb(255, 255, 255))];
|
||||
@@ -465,33 +382,4 @@ mod tests {
|
||||
assert!((r[64].r as i32 - 64).abs() <= 2);
|
||||
assert!(ramp(&[(0.3, Rgba::rgb(9, 9, 9))]).iter().all(|c| *c == Rgba::rgb(9, 9, 9)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_cubics_are_refused_before_flattening() {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
// About 250,000 quads each: over the limit.
|
||||
for _ in 0..3 {
|
||||
p.curve_to((1e15, -1e15), (-1e15, 1e15), (1.0, 1.0));
|
||||
}
|
||||
p.close_path();
|
||||
assert!(polygons(&p).is_empty());
|
||||
// A cubic of ordinary size still flattens.
|
||||
let mut q = BezPath::new();
|
||||
q.move_to((0.0, 0.0));
|
||||
q.curve_to((100.0, 0.0), (100.0, 100.0), (0.0, 100.0));
|
||||
q.close_path();
|
||||
assert_eq!(polygons(&q).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_contours_keep_every_contour_at_a_coarser_raster() {
|
||||
let square = |x: f64| vec![Point::new(x, 0.0), Point::new(x + 1.0, 0.0), Point::new(x + 1.0, 1.0), Point::new(x, 1.0), Point::new(x, 0.0)];
|
||||
let polys: Vec<Vec<Point>> = (0..100).map(|i| square(f64::from(i) * 2.0)).collect();
|
||||
let simple = simplify(polys, 0.001, 244);
|
||||
assert_eq!(simple.len(), 100, "no contour is dropped");
|
||||
let (pw, ph) = fit_raster(400, 512, 512);
|
||||
assert!(u64::from(pw) * u64::from(ph) * 400 <= MAX_EDGE_WORK && pw > 100, "{pw}x{ph}");
|
||||
assert_eq!(fit_raster(200, 512, 512), (512, 512));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,6 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHand
|
||||
|
||||
use crate::icons::{self, Icon};
|
||||
|
||||
/// Video shapes that get a decoder at once; more play their sound without a picture.
|
||||
const MAX_VIDEO_FEEDS: usize = 16;
|
||||
|
||||
/// Who started a voice: the editor's control bar stops when you leave the slide, the slide show
|
||||
/// manages its own.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -230,10 +227,14 @@ impl MediaHost {
|
||||
audio: clip.volume > 0.0,
|
||||
};
|
||||
let source_changed = self.owners.get(&id).is_some_and(|o| o.source != media_key);
|
||||
// A shape whose feed could not be had (the cap, a decode error) tries again on each play.
|
||||
let feedless = self.videos.get(&id).is_some_and(|v| v.feed.is_none());
|
||||
if clip.video && (source_changed || feedless || !self.videos.contains_key(&id)) {
|
||||
let feed = self.new_feed(id, &bytes);
|
||||
if clip.video && (source_changed || !self.videos.contains_key(&id)) {
|
||||
let feed = match VideoFeed::new(bytes.clone()) {
|
||||
Ok(f) => Some(f),
|
||||
Err(e) => {
|
||||
log::info!("video {id}: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
self.videos.insert(id, Video { feed, frame: None, tex: None });
|
||||
} else if !clip.video {
|
||||
self.videos.remove(&id);
|
||||
@@ -242,35 +243,6 @@ impl MediaHost {
|
||||
self.owners.insert(id, Owned { owner, clip: clip.clone(), source: media_key });
|
||||
}
|
||||
|
||||
/// A decoder for the video of shape `id`, within the cap on live decoders; `None` past it or
|
||||
/// when the video cannot be decoded (the shape then plays its sound only).
|
||||
fn new_feed(&mut self, id: ShapeId, bytes: &Arc<Vec<u8>>) -> Option<VideoFeed> {
|
||||
// One decoder and frame queue per video shape: past the cap a shape plays its sound only.
|
||||
let mut live = self.videos.iter().filter(|(vid, v)| **vid != id && v.feed.is_some()).count();
|
||||
if live >= MAX_VIDEO_FEEDS {
|
||||
// Videos that have ended no longer need their decoder: free it and keep the last
|
||||
// picture (a replay makes a new feed).
|
||||
for (vid, v) in self.videos.iter_mut() {
|
||||
if *vid != id && v.feed.is_some() && self.player.status(key(*vid)).is_some_and(|s| s.state == PlayState::Ended) {
|
||||
v.feed = None;
|
||||
live -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if live >= MAX_VIDEO_FEEDS {
|
||||
log::info!("video {id}: more than {MAX_VIDEO_FEEDS} videos playing; showing no picture");
|
||||
None
|
||||
} else {
|
||||
match VideoFeed::new(bytes.clone()) {
|
||||
Ok(f) => Some(f),
|
||||
Err(e) => {
|
||||
log::info!("video {id}: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pause(&mut self, id: ShapeId) {
|
||||
self.player.pause(key(id));
|
||||
}
|
||||
@@ -308,15 +280,6 @@ impl MediaHost {
|
||||
self.play(doc_uid, doc, id, clip, Some(t), owner);
|
||||
self.pause(id);
|
||||
}
|
||||
// A video whose decoder was reclaimed (or refused at the cap) tries for one again, so its picture follows the seek.
|
||||
if clip.video && self.videos.get(&id).is_some_and(|v| v.feed.is_none()) {
|
||||
let bytes = doc.media(clip.media).map(|m| m.data.clone());
|
||||
if let Some(feed) = bytes.and_then(|b| self.new_feed(id, &b))
|
||||
&& let Some(v) = self.videos.get_mut(&id)
|
||||
{
|
||||
v.feed = Some(feed);
|
||||
}
|
||||
}
|
||||
self.player.seek(key(id), t);
|
||||
}
|
||||
|
||||
@@ -841,47 +804,4 @@ mod tests {
|
||||
assert_eq!((a.rect.x, a.rect.y, a.rect.w, a.rect.h), (100.0, 50.0, 20.0, 20.0));
|
||||
assert_eq!((b.rect.x, b.rect.y, b.rect.w, b.rect.h), (120.0, 50.0, 20.0, 20.0));
|
||||
}
|
||||
|
||||
/// Past `MAX_VIDEO_FEEDS` live decoders a video shape plays without a picture, and gets a
|
||||
/// decoder again on its next play once one has been freed.
|
||||
#[test]
|
||||
fn video_decoders_are_capped_and_retried_when_one_frees_up() {
|
||||
let mut doc = Presentation::default();
|
||||
let media = doc.add_media("clip.mp4", "video/mp4", include_bytes!("../../media/tests/fixtures/clip-h264.mp4").to_vec()).expect("media");
|
||||
let clip = MediaClip { media, volume: 0.0, video: true, ..Default::default() };
|
||||
let mut host = MediaHost::new(None);
|
||||
let live = |h: &MediaHost| h.videos.values().filter(|v| v.feed.is_some()).count();
|
||||
for n in 0..=MAX_VIDEO_FEEDS as u32 {
|
||||
host.play(1, &doc, ShapeId(5000 + n), &clip, None, Owner::Editor);
|
||||
}
|
||||
assert_eq!(live(&host), MAX_VIDEO_FEEDS);
|
||||
let extra = ShapeId(5000 + MAX_VIDEO_FEEDS as u32);
|
||||
assert!(host.videos.get(&extra).is_some_and(|v| v.feed.is_none()), "the shape past the cap has no picture");
|
||||
host.stop(ShapeId(5000));
|
||||
host.play(1, &doc, extra, &clip, None, Owner::Editor);
|
||||
assert!(host.videos.get(&extra).is_some_and(|v| v.feed.is_some()), "a freed decoder is taken on the next play");
|
||||
assert_eq!(live(&host), MAX_VIDEO_FEEDS);
|
||||
}
|
||||
|
||||
/// Seeking a video that has no decoder (refused at the cap, or reclaimed) retries one without
|
||||
/// restarting playback, so its picture follows the seek.
|
||||
#[test]
|
||||
fn seeking_a_feedless_video_retries_its_decoder() {
|
||||
let mut doc = Presentation::default();
|
||||
let media = doc.add_media("clip.mp4", "video/mp4", include_bytes!("../../media/tests/fixtures/clip-h264.mp4").to_vec()).expect("media");
|
||||
let clip = MediaClip { media, volume: 0.0, video: true, ..Default::default() };
|
||||
let mut host = MediaHost::new(None);
|
||||
for n in 0..=MAX_VIDEO_FEEDS as u32 {
|
||||
host.play(1, &doc, ShapeId(7000 + n), &clip, None, Owner::Editor);
|
||||
}
|
||||
let extra = ShapeId(7000 + MAX_VIDEO_FEEDS as u32);
|
||||
assert!(host.videos.get(&extra).is_some_and(|v| v.feed.is_none()));
|
||||
// Still at the cap: a seek does not exceed it.
|
||||
host.seek(1, &doc, extra, &clip, 0.0, Owner::Editor);
|
||||
assert!(host.videos.get(&extra).is_some_and(|v| v.feed.is_none()));
|
||||
host.stop(ShapeId(7000));
|
||||
host.seek(1, &doc, extra, &clip, 0.0, Owner::Editor);
|
||||
assert!(host.videos.get(&extra).is_some_and(|v| v.feed.is_some()), "the seek got a decoder");
|
||||
assert_eq!(host.videos.values().filter(|v| v.feed.is_some()).count(), MAX_VIDEO_FEEDS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::edit;
|
||||
use deckcraft_model::text::{Bullet, Paragraph, RunProps, Strike, TextBody};
|
||||
use egui::text::{Galley, LayoutJob, TextFormat};
|
||||
use egui::text::{CCursor, Galley, LayoutJob, TextFormat};
|
||||
use egui::{Align2, Color32, Stroke, Ui, vec2};
|
||||
|
||||
use crate::theme;
|
||||
@@ -210,30 +210,16 @@ pub fn editor(
|
||||
out
|
||||
}
|
||||
|
||||
/// The (min y, max y) of the row holding char index `at`; `walk` (row index, chars through that
|
||||
/// row) carries over between calls, so successive non-decreasing `at`s cost one pass in all.
|
||||
fn row_span_at(galley: &Galley, walk: &mut (usize, usize), at: usize) -> (f32, f32) {
|
||||
while walk.0 + 1 < galley.rows.len() && (walk.1 < at || (walk.1 == at && galley.rows[walk.0].char_count_including_newline().0 > 0)) {
|
||||
walk.0 += 1;
|
||||
walk.1 += galley.rows[walk.0].char_count_including_newline().0;
|
||||
}
|
||||
galley.rows.get(walk.0).map_or((0.0, 0.0), |r| (r.min_y(), r.max_y()))
|
||||
}
|
||||
|
||||
/// Bullets and numbers at the start of each paragraph's first line of `galley` (laid out from
|
||||
/// `body`), drawn at `origin`.
|
||||
fn paint_bullets(painter: &egui::Painter, body: &TextBody, galley: &Galley, origin: egui::Pos2, size: f32, color: Color32) {
|
||||
let k = size / 12.0;
|
||||
let mut at = 0usize;
|
||||
// Paragraph starts only grow, so one forward walk over the rows finds each start's row
|
||||
// (`pos_from_cursor` would rescan from the top for every bullet).
|
||||
let mut walk = (0usize, galley.rows.first().map_or(0, |r| r.char_count_including_newline().0));
|
||||
for (p, bullet) in body.paragraphs.iter().zip(bullets(body)) {
|
||||
if let Some(b) = bullet {
|
||||
let (min_y, max_y) = row_span_at(galley, &mut walk, at);
|
||||
let (center_y, height) = ((min_y + max_y) / 2.0, max_y - min_y);
|
||||
let pos = origin + vec2(px(indents(p).0, k), center_y);
|
||||
let font = theme::font(height.clamp(size * 0.75, size * 2.0) * 0.8);
|
||||
let row = galley.pos_from_cursor(CCursor::new(at));
|
||||
let pos = origin + vec2(px(indents(p).0, k), row.center().y);
|
||||
let font = theme::font(row.height().clamp(size * 0.75, size * 2.0) * 0.8);
|
||||
painter.text(pos, Align2::LEFT_CENTER, b, font, color);
|
||||
}
|
||||
at += p.char_len() + 1;
|
||||
@@ -255,7 +241,6 @@ mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use deckcraft_model::text::{ParaProps, Run};
|
||||
use egui::text::CCursor;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -332,23 +317,6 @@ mod tests {
|
||||
assert!(wrapped.iter().all(|r| (r.pos.x - 22.5).abs() < 1.0), "wrapped lines align with the text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_walk_matches_cursor_lookup_at_paragraph_starts() {
|
||||
let ctx = egui::Context::default();
|
||||
let body = notes();
|
||||
let mut laid = None;
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| laid = Some(galley(ui, &body, 12.0, Color32::BLACK, 300.0)));
|
||||
out.textures_delta.clear();
|
||||
let g = laid.unwrap();
|
||||
let mut walk = (0usize, g.rows.first().map_or(0, |r| r.char_count_including_newline().0));
|
||||
let mut at = 0usize;
|
||||
for p in &body.paragraphs {
|
||||
let want = g.pos_from_cursor(CCursor::new(at));
|
||||
assert_eq!(row_span_at(&g, &mut walk, at), (want.min.y, want.max.y), "paragraph at char {at}");
|
||||
at += p.char_len() + 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_keeps_the_formatting_around_it() {
|
||||
let mut d = Draft::new(¬es());
|
||||
|
||||
@@ -196,40 +196,18 @@ pub(super) fn part_textures(ctx: &egui::Context, doc: &Presentation, from: Optio
|
||||
]
|
||||
}
|
||||
|
||||
/// Width of a slide-show grid thumbnail, in pixels.
|
||||
const THUMB_WIDTH: f64 = 320.0;
|
||||
/// Tallest grid thumbnail, so an extreme portrait slide cannot ask for a huge raster.
|
||||
const THUMB_MAX_HEIGHT: f64 = 2048.0;
|
||||
|
||||
/// The scale and pixel size of a grid thumbnail of a `width` x `height` slide.
|
||||
fn thumb_size(width: f64, height: f64) -> (f64, f64, f64) {
|
||||
let (sw, sh) = (width.max(1.0), height.max(1.0));
|
||||
let scale = (THUMB_WIDTH / sw).min(THUMB_MAX_HEIGHT / sh);
|
||||
(scale, (sw * scale).round().max(1.0), (sh * scale).round().max(1.0))
|
||||
}
|
||||
|
||||
pub(super) fn render_thumb(ctx: &egui::Context, show: &mut Show, doc: &Presentation, i: usize) -> Option<TextureHandle> {
|
||||
if show.thumbs.len() >= GRID_CAP {
|
||||
return None;
|
||||
}
|
||||
// 320 wide, unless the slide is so tall that this would pass the height limit: fit that box.
|
||||
let (scale, w, h) = thumb_size(doc.slide_size.width, doc.slide_size.height);
|
||||
let img = deckcraft_render::render_slide(doc, i, &RenderOpts { scale, size: Some((w as u32, h as u32)), ..Default::default() });
|
||||
let w = 320.0_f64;
|
||||
let h = (w * doc.slide_size.height / doc.slide_size.width.max(1.0)).round().max(1.0);
|
||||
let img = deckcraft_render::render_slide(
|
||||
doc,
|
||||
i,
|
||||
&RenderOpts { scale: w / doc.slide_size.width.max(1.0), size: Some((w as u32, h as u32)), ..Default::default() },
|
||||
);
|
||||
let tex = crate::textures::upload(ctx, format!("show-grid-{i}"), crate::textures::to_color_image(&img, false));
|
||||
show.thumbs.push((i, tex.clone()));
|
||||
Some(tex)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod thumb_size_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ordinary_slides_are_320_wide_and_extreme_ones_stay_in_the_height_limit() {
|
||||
let (scale, w, h) = thumb_size(960.0, 540.0);
|
||||
assert_eq!((w, h), (320.0, 180.0));
|
||||
assert!((scale - 320.0 / 960.0).abs() < 1e-12);
|
||||
let (_, w, h) = thumb_size(1.0, 1.0e9);
|
||||
assert!(h <= THUMB_MAX_HEIGHT && w >= 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user