diff --git a/apps/AGENTS.md b/apps/AGENTS.md
index e1f96eea..055ec949 100644
--- a/apps/AGENTS.md
+++ b/apps/AGENTS.md
@@ -127,7 +127,12 @@ app's own agent and `tools.json`, the system toolbox, `glance.publish` and
most 32, kept across closes and named by the file's name, size and
modification time (`src/covers.rs`). Slide drawings go before covers,
each oldest first, and the whole cache is cleared before a write would
- fail with `storage_full:`. Errors start with a stable code
+ fail with `storage_full:`. What the inspector shows beyond the
+ commands' answers (a box's anchor and autofit, a chart's and a table's
+ options, the deck's colour and font schemes and its footer, the engine's
+ colour schemes with their colours) the service reads from the open
+ document's model, read-only (`src/reading.rs`, `docs::design_state`).
+ Errors start with a stable code
(`src/codes.rs`). Every call first waits for a warm-up that is still
running (`src/warm.rs`): deckcraft's font database cannot load one
family on two threads at once, and a slide drawn during that race kept
diff --git a/apps/deck/host-service/src/doc_tests.rs b/apps/deck/host-service/src/doc_tests.rs
index f652c1c4..708e06f3 100644
--- a/apps/deck/host-service/src/doc_tests.rs
+++ b/apps/deck/host-service/src/doc_tests.rs
@@ -283,7 +283,12 @@ fn creating_a_deck_writes_it_as_made() {
fn state_close_and_unsaved_changes() {
let w = World::new();
let doc = w.sample_open("board.deckcraft", 3);
- assert_eq!(w.ok("state", json!({ "doc": doc })), json!({ "rev": 0, "edited": false, "can_undo": false, "can_redo": false, "undo": null, "redo": null, "slides": 3 }));
+ let footer = json!({ "slide_numbers": false, "footer": false, "footer_text": "", "date": false, "hide_on_title": false });
+ assert_eq!(
+ w.ok("state", json!({ "doc": doc })),
+ json!({ "rev": 0, "edited": false, "can_undo": false, "can_redo": false, "undo": null, "redo": null, "slides": 3,
+ "theme": "DeckCraft", "color_scheme": "DeckCraft", "font_scheme": "DeckCraft", "footer": footer })
+ );
let ids = w.slide_ids(&doc);
w.ok("notes", json!({ "doc": doc, "slide": ids[0], "text": "Welcome everyone" }));
let state = w.ok("state", json!({ "doc": doc }));
@@ -575,9 +580,21 @@ fn the_import_guard_refuses_before_the_engine_runs_on_every_path() {
#[test]
fn the_guards_reader_reads_as_open_presentation() {
let deck = sample(5);
+ // A package whose slide is wider than the engine keeps: the package
+ // reader takes up to 20,000 points, and `repair` narrows it to 4,000,
+ // so a reader that skipped `repair` would read another deck.
+ let wide = fx::with_rewritten(&pptx(&deck), "ppt/presentation.xml", |xml| {
+ let xml = String::from_utf8_lossy(xml);
+ assert!(xml.contains("cx=\"12192000\""), "the 960-point slide width is there to widen");
+ xml.replacen("cx=\"12192000\"", "cx=\"63500000\"", 1).into_bytes()
+ });
+ let raw = deckcraft_pptx::import(&wide).unwrap();
+ assert_eq!(raw.slide_size.width, 5000.0, "the package reader keeps the width");
+ assert_eq!(engine_file::open_presentation("wide.pptx", &wide).unwrap().slide_size.width, 4000.0, "repair narrows it");
for (name, bytes, format) in [
("x.deckcraft", native(&deck), guard::Format::Native),
("x.pptx", pptx(&deck), guard::Format::Pptx),
+ ("wide.pptx", wide, guard::Format::Pptx),
("x.txt", b"Title\n\tPoint\nSecond\n\tAnother point\n".to_vec(), guard::Format::Outline),
("x.md", b"".to_vec(), guard::Format::Outline),
] {
@@ -626,6 +643,103 @@ fn a_package_with_its_content_types_late_opens_on_every_path() {
}
}
+/// An Office package whose main part is not a presentation (a document or
+/// a workbook, written by Office or laid out as LibreOffice writes one,
+/// its content types last) is `unsupported:`, not `damaged:`.
+#[test]
+fn an_office_document_that_is_not_a_presentation_is_unsupported() {
+ let w = World::new();
+ let rels = |target: &str| format!("");
+ let types = |main: &str, ct: &str| Part::deflated("[Content_Types].xml", format!("").as_bytes());
+ let word = "Minutes";
+ let docx_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml";
+ let book = "";
+ let xlsx_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml";
+ // As Office writes them: the content types first.
+ let office_docx = fx::zip(&[types("word/document.xml", docx_type), Part::deflated("_rels/.rels", rels("word/document.xml").as_bytes()), Part::deflated("word/document.xml", word.as_bytes())]);
+ let office_xlsx = fx::zip(&[types("xl/workbook.xml", xlsx_type), Part::deflated("_rels/.rels", rels("xl/workbook.xml").as_bytes()), Part::deflated("xl/workbook.xml", book.as_bytes())]);
+ // As LibreOffice lays one out: its content types last, past 4 KiB.
+ let styles = format!("{}", "".repeat(200));
+ let libre_docx = fx::zip(&[
+ Part::deflated("_rels/.rels", rels("word/document.xml").as_bytes()),
+ Part::stored("word/styles.xml", styles.as_bytes()),
+ Part::deflated("word/document.xml", word.as_bytes()),
+ types("word/document.xml", docx_type),
+ ]);
+ assert!(libre_docx.windows(19).position(|win| win == b"[Content_Types].xml").unwrap() > 4096);
+ for (name, bytes) in [("minutes.docx", office_docx), ("budget.xlsx", office_xlsx), ("minutes-lo.docx", libre_docx)] {
+ w.put(name, &bytes);
+ for method in ["open", "cover"] {
+ let e = w.err(method, json!({ "path": name }));
+ coded(&e, "unsupported");
+ assert!(e.contains("not a presentation"), "{name} {method}: {e}");
+ }
+ }
+ assert_eq!(docs::open_count(), 0);
+}
+
+/// What the inspector shows, read from the open document's model: the
+/// deck's colour and font schemes and its footer (on opening and in its
+/// state, so an undo shows too), a box's anchor and autofit, a chart's
+/// options and a table's, and the engine's colour schemes with their
+/// colours.
+#[test]
+fn the_inspector_reads_the_decks_design_and_its_objects() {
+ let w = World::new();
+ let doc = w.sample_open("board.deckcraft", 5);
+ let ids = w.slide_ids(&doc);
+ // The design: the default theme's own schemes, no footer.
+ let opened = w.ok("state", json!({ "doc": doc }));
+ assert_eq!((opened["theme"].clone(), opened["color_scheme"].clone(), opened["font_scheme"].clone()), (json!("DeckCraft"), json!("DeckCraft"), json!("DeckCraft")), "{opened}");
+ let none = json!({ "slide_numbers": false, "footer": false, "footer_text": "", "date": false, "hide_on_title": false });
+ assert_eq!(opened["footer"], none);
+ let footer = json!({ "slide_numbers": true, "footer": true, "footer_text": "Board", "date": true, "hide_on_title": true });
+ w.ok("design", json!({ "doc": doc, "colors": "Ocean", "fonts": "Classic", "footer": footer }));
+ let state = w.ok("state", json!({ "doc": doc }));
+ assert_eq!((state["color_scheme"].clone(), state["font_scheme"].clone(), state["footer"].clone()), (json!("Ocean"), json!("Classic"), footer.clone()), "{state}");
+ w.ok("save", json!({ "doc": doc }));
+ w.ok("close", json!({ "doc": doc }));
+ let again = w.open("board.deckcraft");
+ let reopened = w.ok("open", json!({ "path": "board.deckcraft" }));
+ assert_eq!((reopened["color_scheme"].clone(), reopened["font_scheme"].clone(), reopened["footer"].clone()), (json!("Ocean"), json!("Classic"), footer), "{reopened}");
+ w.ok("close", json!({ "doc": reopened["doc"] }));
+ w.ok("design", json!({ "doc": again, "colors": "Berry" }));
+ w.ok("undo", json!({ "doc": again }));
+ assert_eq!(w.ok("state", json!({ "doc": again }))["color_scheme"], "Ocean", "an undo shows");
+ // The engine's colour schemes, each with its twelve colours.
+ let themes = w.ok("themes", json!({}));
+ let ocean = themes["color_schemes"].as_array().unwrap().iter().find(|c| c["name"] == "Ocean").cloned().unwrap();
+ let colors = ocean["colors"].as_array().unwrap();
+ assert_eq!(colors.len(), 12);
+ assert_eq!((colors[2].clone(), colors[4].clone()), (json!("#13293D"), json!("#006494")), "dk2 and accent1, in the engine's order");
+ assert_eq!(themes["color_schemes"].as_array().unwrap().len(), deckcraft_model::theme::builtin_color_schemes().len());
+ // A box's anchor and autofit as it draws them: a body placeholder
+ // shrinks its text as its master says, a text box resizes to its text;
+ // then as set.
+ let body = w.shape_where(&again, ids[1], "text", "First point\nSecond point");
+ let state = w.ok("format_state", json!({ "doc": again, "slide": ids[1], "id": body }));
+ assert_eq!((state["anchor"].clone(), state["autofit"].clone()), (json!("top"), json!("shrink")), "{state}");
+ let tb = w.ok("insert", json!({ "doc": again, "slide": ids[1], "kind": "text", "text": "Note" }))["id"].clone();
+ assert_eq!(w.ok("format_state", json!({ "doc": again, "slide": ids[1], "id": tb }))["autofit"], "resize");
+ w.ok("format", json!({ "doc": again, "slide": ids[1], "ids": [body], "anchor": "bottom", "autofit": "none" }));
+ let state = w.ok("format_state", json!({ "doc": again, "slide": ids[1], "id": body }));
+ assert_eq!((state["anchor"].clone(), state["autofit"].clone()), (json!("bottom"), json!("none")), "{state}");
+ // A chart's options and a table's, as deck.chart and deck.table take them.
+ let shapes = |slide: u64| w.ok("slide", json!({ "doc": again, "slide": slide }))["shapes"].as_array().unwrap().clone();
+ let chart = shapes(ids[3]).into_iter().find(|s| s["chart"].is_object()).unwrap();
+ let c = &chart["chart"];
+ assert_eq!((c["legend"].clone(), c["data_labels"].clone(), c["gridlines"].clone(), c["palette"].clone()), (json!("b"), json!(false), json!(true), json!("colorful1")), "{c}");
+ w.ok("chart", json!({ "doc": again, "slide": ids[3], "id": chart["id"], "legend": "none", "data_labels": true, "gridlines": false, "palette": "monochrome3" }));
+ let c = shapes(ids[3]).into_iter().find(|s| s["chart"].is_object()).unwrap()["chart"].clone();
+ assert_eq!((c["legend"].clone(), c["data_labels"].clone(), c["gridlines"].clone(), c["palette"].clone()), (json!("none"), json!(true), json!(false), json!("monochrome3")), "{c}");
+ let table = shapes(ids[2]).into_iter().find(|s| s["table"].is_object()).unwrap();
+ let defaults = json!({ "header_row": true, "total_row": false, "banded_rows": true, "first_column": false, "last_column": false, "banded_columns": false });
+ assert_eq!(table["table"]["options"], defaults, "{table}");
+ w.ok("table", json!({ "doc": again, "slide": ids[2], "id": table["id"], "op": "options", "header_row": false, "banded_columns": true }));
+ let options = shapes(ids[2]).into_iter().find(|s| s["table"].is_object()).unwrap()["table"]["options"].clone();
+ assert_eq!((options["header_row"].clone(), options["banded_columns"].clone(), options["banded_rows"].clone()), (json!(false), json!(true), json!(true)), "{options}");
+}
+
/// The import guard passes what the engine itself writes, at the sizes the
/// service allows: a native deck's `presentation.json` repeats itself from
/// slide to slide, so its compression ratio is the one to watch.
@@ -847,7 +961,7 @@ fn layouts_themes_fonts_and_shapes() {
let names: Vec<&str> = themes["themes"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect();
assert_eq!(names, ["DeckCraft", "Harbor", "Ember", "Meadow", "Nocturne", "Paper", "Coral Reef", "Slate"]);
assert_eq!(themes["themes"][0]["label"], "Default");
- assert!(themes["color_schemes"].as_array().unwrap().iter().any(|c| c == "Ocean"), "{themes}");
+ assert!(themes["color_schemes"].as_array().unwrap().iter().any(|c| c["name"] == "Ocean" && c["colors"].as_array().is_some_and(|k| k.len() == 12)), "{themes}");
assert!(themes["font_schemes"].as_array().unwrap().iter().any(|f| f["name"] == "Office Compatible"), "{themes}");
let fonts = w.ok("fonts", json!({}));
assert!(fonts["families"].as_array().is_some_and(|f| !f.is_empty()), "at least the last-resort face: {fonts}");
diff --git a/apps/deck/host-service/src/docs.rs b/apps/deck/host-service/src/docs.rs
index 2137531e..647c3587 100644
--- a/apps/deck/host-service/src/docs.rs
+++ b/apps/deck/host-service/src/docs.rs
@@ -414,6 +414,7 @@ fn opened(doc: &mut OpenDoc, cx: &Ctx, m: &'static str, path: &str) -> Result Result Result {
out["undo"] = inspected["undo"].clone();
out["redo"] = inspected["redo"].clone();
out["slides"] = json!(inspected["slides"].as_array().map_or(0, Vec::len));
+ let deck = doc.deck()?;
+ let design = design_state(&deck);
+ out["theme"] = json!(deck.masters.first().map(|m| m.theme.name.clone()));
+ for key in ["color_scheme", "font_scheme", "footer"] {
+ out[key] = design[key].clone();
+ }
Ok(out)
})
}
+
+/// The deck's design as the inspector shows it, read from the model: the
+/// first master's colour and font scheme names (the master
+/// `document.inspect` reports the theme of), and the deck's header and
+/// footer settings in the keys `deck.design {footer}` takes.
+pub(crate) fn design_state(deck: &Presentation) -> Json {
+ let master = deck.masters.first();
+ let hf = &deck.header_footer;
+ json!({
+ "color_scheme": master.map(|m| m.theme.colors.name.clone()),
+ "font_scheme": master.map(|m| m.theme.fonts.name.clone()),
+ "footer": {
+ "slide_numbers": hf.slide_number,
+ "footer": hf.footer,
+ "footer_text": hf.footer_text,
+ "date": hf.date,
+ "hide_on_title": hf.hide_on_title,
+ },
+ })
+}
diff --git a/apps/deck/host-service/src/guard.rs b/apps/deck/host-service/src/guard.rs
index abb5038f..dd49d2fb 100644
--- a/apps/deck/host-service/src/guard.rs
+++ b/apps/deck/host-service/src/guard.rs
@@ -1068,6 +1068,22 @@ pub(crate) mod fixtures {
zip(&parts)
}
+ /// The zip `bytes` again (as [`with_parts`]), the entry named `name`
+ /// rewritten by `edit`.
+ pub(crate) fn with_rewritten(bytes: &[u8], name: &str, edit: impl Fn(&[u8]) -> Vec) -> Vec {
+ let dir = super::directory(bytes).unwrap();
+ let parts: Vec = dir
+ .entries
+ .iter()
+ .filter(|e| !e.name.ends_with('/'))
+ .map(|e| {
+ let data = super::inflate(bytes, e, true, &mut |_| {}).unwrap().kept.unwrap();
+ Part::deflated(&e.name, &if e.name == name { edit(&data) } else { data })
+ })
+ .collect();
+ zip(&parts)
+ }
+
/// A native deck's skeleton: its `mimetype` and `presentation.json`.
pub(crate) fn native(extra: Vec, json: &str) -> Vec {
let mut parts = vec![Part::stored("mimetype", deckcraft_format::MIMETYPE.as_bytes()), Part::deflated("presentation.json", json.as_bytes())];
diff --git a/apps/deck/host-service/src/lib.rs b/apps/deck/host-service/src/lib.rs
index fcc88547..e0e36e98 100644
--- a/apps/deck/host-service/src/lib.rs
+++ b/apps/deck/host-service/src/lib.rs
@@ -657,6 +657,10 @@ pub(crate) fn read_guarded(area: &Area, rel: &str, key: &str, m: &str) -> Result
let words = e.to_string();
if words.contains("not a presentation DeckCraft can read") {
fail(Code::Unsupported, "this is not a presentation the deck engine reads (.pptx, .deckcraft, or outline .txt or .md)")
+ } else if words.contains("is not a presentation part") {
+ // An Office package whose main part is a document or a workbook
+ // (`deckcraft_pptx::import`'s refusal of a .docx or a .xlsx).
+ fail(Code::Unsupported, "this is an Office document but not a presentation, which the deck engine doesn't read (.pptx, .deckcraft, or outline .txt or .md)")
} else {
fail(Code::Damaged, format!("the deck engine couldn't read it: {words}"))
}
diff --git a/apps/deck/host-service/src/reading.rs b/apps/deck/host-service/src/reading.rs
index 946f1262..21b95929 100644
--- a/apps/deck/host-service/src/reading.rs
+++ b/apps/deck/host-service/src/reading.rs
@@ -12,7 +12,12 @@
//! deckcraft-render's `render_slide` and `render_layout` (each one bounded
//! raster, written only into the service's own cache, [`cache::store`]),
//! deckcraft-anim's `show_order`, `ShowState::steps` and `Timeline::state`,
-//! `slides_to_outline`, and the font database's `families`.
+//! `slides_to_outline`, and the font database's `families`. Besides the
+//! commands' answers, the service reads what the inspector shows straight
+//! from the open document's model, read-only (`deckcraft_model`): a box's
+//! anchor and autofit (`resolve::body`, with what it inherits), a chart's
+//! legend, data labels, gridlines and palette, a table's style options, and
+//! the engine's built-in colour schemes with their colours.
use deckcraft_anim::{ShowState, Timeline};
use deckcraft_engine::geom::{Point, Vec2};
@@ -20,7 +25,7 @@ use deckcraft_engine::render::{self as render, ParaState, RenderOpts, ShapeState
use deckcraft_engine::tools::Hit;
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::style::Dash;
-use deckcraft_model::text::{Align, Spacing};
+use deckcraft_model::text::{Align, Anchor, AutoFit, Spacing};
use deckcraft_model::{Fill, Presentation, Shape, ShapeId, ShapeKind, Slide};
use serde_json::{json, Value as Json};
@@ -201,6 +206,27 @@ fn shape_json(ctx: Option<&Ctx>, summary: &Json, sh: &Shape) -> Json {
if sh.locked {
out["locked"] = json!(true);
}
+ // What `slide.inspect` leaves out of a chart and a table, from the model,
+ // in the keys `deck.chart` and `deck.table {op: "options"}` take.
+ match &sh.kind {
+ ShapeKind::Chart(c) if out["chart"].is_object() => {
+ out["chart"]["legend"] = json!(c.legend.as_deref().unwrap_or("none"));
+ out["chart"]["data_labels"] = json!(c.data_labels);
+ out["chart"]["gridlines"] = json!(c.gridlines);
+ out["chart"]["palette"] = json!(c.palette);
+ }
+ ShapeKind::Table(t) if out["table"].is_object() => {
+ out["table"]["options"] = json!({
+ "header_row": t.first_row,
+ "total_row": t.last_row,
+ "banded_rows": t.band_row,
+ "first_column": t.first_col,
+ "last_column": t.last_col,
+ "banded_columns": t.band_col,
+ });
+ }
+ _ => {}
+ }
if let ShapeKind::Picture { fill } = &sh.kind {
out["picture"] = json!({
"crop": fill.crop,
@@ -408,7 +434,40 @@ pub(crate) fn themes(a: &Json, cx: &Call) -> Result {
json!({ "name": name, "label": if name == "DeckCraft" { "Default" } else { name }, "colors": t["colors"], "major": t["major"], "minor": t["minor"] })
})
.collect();
- Ok(json!({ "themes": themes, "color_schemes": got["colorSchemes"], "font_schemes": got["fontSchemes"] }))
+ Ok(json!({ "themes": themes, "color_schemes": color_schemes(), "font_schemes": got["fontSchemes"] }))
+}
+
+/// The engine's built-in colour schemes, each with its twelve colours in the
+/// engine's order (dk1, lt1, dk2, lt2, accent1 to accent6, hlink, folHlink),
+/// as `design.themes` gives a theme's: the list `design.colors {name}`
+/// chooses from (`deckcraft_model::theme::builtin_color_schemes`, which
+/// `design.themes` names only).
+fn color_schemes() -> Json {
+ json!(deckcraft_model::theme::builtin_color_schemes()
+ .iter()
+ .map(|c| json!({ "name": c.name, "colors": c.colors.iter().map(|x| format!("#{}", x.hex())).collect::>() }))
+ .collect::>())
+}
+
+/// A box's vertical anchor as `deck.format` names it (`top`, `middle`,
+/// `bottom`), or as the model names the two it doesn't set.
+fn anchor_name(a: Anchor) -> &'static str {
+ match a {
+ Anchor::Top => "top",
+ Anchor::Middle => "middle",
+ Anchor::Bottom => "bottom",
+ Anchor::Justified => "justified",
+ Anchor::Distributed => "distributed",
+ }
+}
+
+/// A box's autofit as `deck.format` names it.
+fn autofit_name(a: &AutoFit) -> &'static str {
+ match a {
+ AutoFit::None => "none",
+ AutoFit::Shrink { .. } => "shrink",
+ AutoFit::Shape => "resize",
+ }
}
/// `deck.fonts {}`: what the engine can draw on this device.
@@ -487,6 +546,12 @@ pub(crate) fn format_state(a: &Json, cx: &Call) -> Result {
}
let got = gate::read(doc, cx.area, Plan { method: M, most: FORMAT_STATE, cmds })?;
let state = &got[got.len() - if paragraph.is_some() { 2 } else { 1 }];
+ // The box's anchor and autofit, as it draws them: its own, else what
+ // its layout and master placeholders give, else the engine's
+ // defaults (`resolve::body`).
+ let body = Ctx::for_slide(&deck, &deck.slides[i]).map(|ctx| resolve::body(&ctx, &sh));
+ let anchor = body.as_ref().and_then(|b| b.anchor).map(anchor_name);
+ let autofit = body.as_ref().and_then(|b| b.autofit.as_ref()).map(autofit_name);
// The colour of the same run format.state reads, resolved.
let color = Ctx::for_slide(&deck, &deck.slides[i]).map(|ctx| {
let body = sh.text.clone().unwrap_or_default();
@@ -515,6 +580,8 @@ pub(crate) fn format_state(a: &Json, cx: &Call) -> Result {
Some(Spacing::Pts(pt)) => json!({ "pt": pt }),
None => Json::Null,
},
+ "anchor": anchor,
+ "autofit": autofit,
}))
})
}