From 171fefa544c8b0dd46558dd9138fb556f6b89f66 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:58:09 -0700 Subject: [PATCH] deck: the inspector's states, read from the model Quick Deck v2's inspector shows these as off, or by name only, today. The service now reads each from the open document's model (deckcraft-model), read-only, with no new engine command: - deck.themes: color_schemes as [{name, colors}], the engine's built-in schemes (builtin_color_schemes, which design.themes names only) with their twelve colours in the engine's order - deck.open and deck.state: theme (state), color_scheme, font_scheme (the first master's, as document.inspect reports the theme) and footer {slide_numbers, footer, footer_text, date, hide_on_title}, the keys deck.design {footer} takes; in deck.state too, so an undo shows - deck.format_state: anchor and autofit as the box draws them (resolve::body: its own, else its layout's and master's, else the engine's defaults), in deck.format's words - deck.slide: a chart's legend, data_labels, gridlines and palette, and a table's options {header_row, total_row, banded_rows, first_column, last_column, banded_columns}, as deck.chart and deck.table take them Review nits on #503: - the reader-parity test now includes a package that repair visibly changes (a slide 5,000 points wide, which repair narrows to 4,000), so dropping repair from engine_read fails it (tried, then restored) - an Office package whose main part is a document or a workbook (.docx, .xlsx, written by Office or laid out as LibreOffice writes one) is unsupported:, not damaged: Co-Authored-By: Claude Opus 5.5 --- apps/AGENTS.md | 7 +- apps/deck/host-service/src/doc_tests.rs | 118 +++++++++++++++++++++++- apps/deck/host-service/src/docs.rs | 30 ++++++ apps/deck/host-service/src/guard.rs | 16 ++++ apps/deck/host-service/src/lib.rs | 4 + apps/deck/host-service/src/reading.rs | 73 ++++++++++++++- 6 files changed, 242 insertions(+), 6 deletions(-) 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, })) }) }