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, })) }) }