mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-10 12:29:58 -06:00
Merge origin/main (6f52cc50, the squash of upstream pull request 471) into w/form-controls
Both sides added a kind of floating object (equation, form control); both are kept everywhere. ROADMAP.md: header combined (form controls first, then equations), catalog 281 / 296 as `cargo xtask parity` measures, the form-controls progress-log and revision rows above main's equation rows. crates/engine/src/cmd.rs: both spec lists (controls and equation). crates/engine/src/cmd/extra.rs: the arrange params docs list both kinds. crates/engine/src/cmd/extra/objects.rs: main's `found` match and `anchors_of` on ObjKind gain the Control arms; the unlocked check takes the parsed kind directly. crates/engine/src/cmd/insert.rs: the object params docs and the kind error name both kinds. crates/engine/src/tests.rs: both test modules. crates/engine/src/tests/objects.rs: every_kind returns six objects (equation at L18, slider at P18) and the anchor lists carry both. crates/model/src/features.rs: ObjKind has Equation and Control, ALL lists six kinds, name() both. crates/model/src/sheet.rs: both imports, both accessor arms, the order test inserts equations (6, 8) and controls (13, 15) and documents controls after slicers with equations counted as shapes. crates/model/src/workbook.rs: main's refresh_equations and this branch's free-id doc comment. crates/ui-egui/src/chartview.rs: both doc comments combined. crates/ui-egui/src/objects.rs: both OpenAction variants and arms. crates/ui-egui/src/ribbon/insert.rs: the import list is the union. docs/control-protocol.md: the kind list names equation and control; main's equation sentences and this branch's form-controls sentence. docs/file-format-parity.md: header combined; the xlsx row carries both additions; the form-controls revision row above main's equation rows. docs/parity-checklist.md: regenerated with `cargo xtask parity` (281 / 296). docs/target-app-parity.md: header combined; catalog 281 / 296; the Insert objects row carries both additions; the form-controls revision row (277 / 292 → 281 / 296) above main's. docs/ui-parity.md: header combined with catalog 281 / 296; main's move-and-resize row with equations plus the form-controls row; the form-controls revision row above main's.
This commit is contained in:
@@ -23,6 +23,7 @@ log = { workspace = true }
|
||||
gridcraft-xlsx = { workspace = true }
|
||||
gridcraft-chart = { workspace = true }
|
||||
gridcraft-pdf = { workspace = true }
|
||||
gridcraft-math = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod controls;
|
||||
pub mod data;
|
||||
pub mod draw;
|
||||
pub mod edit;
|
||||
pub mod equation;
|
||||
pub mod extra;
|
||||
pub mod file;
|
||||
pub mod forecast;
|
||||
@@ -41,7 +42,11 @@ pub struct CommandSpec {
|
||||
pub label: &'static str,
|
||||
/// Ribbon/menu placement, e.g. `["Home", "Font"]`. Empty = not in menus.
|
||||
pub menu: &'static [&'static str],
|
||||
/// Portable notation: `Cmd` is ⌘ on macOS and Ctrl elsewhere.
|
||||
pub shortcut: Option<&'static str>,
|
||||
/// macOS override when its key differs. Equal to `shortcut` marks a Mac-only shortcut
|
||||
/// (Align Left/Center): other platforms show none. See [`CommandInfo::shortcut_for_platform`].
|
||||
pub shortcut_mac: Option<&'static str>,
|
||||
pub params: &'static str,
|
||||
pub enabled: Enabled,
|
||||
pub run: Run,
|
||||
@@ -55,12 +60,32 @@ pub struct CommandInfo {
|
||||
pub label: &'static str,
|
||||
pub menu: Vec<&'static str>,
|
||||
pub shortcut: Option<&'static str>,
|
||||
pub shortcut_mac: Option<&'static str>,
|
||||
pub params: &'static str,
|
||||
pub enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disabled_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl CommandInfo {
|
||||
/// Format the portable shortcut notation for the runtime operating system.
|
||||
pub fn shortcut_for_platform(&self, mac: bool) -> Option<String> {
|
||||
let keys = if mac {
|
||||
self.shortcut_mac.or(self.shortcut)
|
||||
} else if self.shortcut_mac.is_some() && self.shortcut_mac == self.shortcut {
|
||||
// An identical Mac override marks a Mac-only display shortcut.
|
||||
// The portable field remains unchanged for legacy CLI consumers.
|
||||
None
|
||||
} else {
|
||||
self.shortcut
|
||||
}?;
|
||||
if mac {
|
||||
return Some(keys.to_owned());
|
||||
}
|
||||
Some(keys.split('+').map(|part| if part == "Cmd" { "Ctrl" } else { part }).collect::<Vec<_>>().join("+"))
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandSpec {
|
||||
pub fn info(&self, s: &Session) -> CommandInfo {
|
||||
let e = (self.enabled)(s);
|
||||
@@ -69,6 +94,7 @@ impl CommandSpec {
|
||||
label: self.label,
|
||||
menu: self.menu.to_vec(),
|
||||
shortcut: self.shortcut,
|
||||
shortcut_mac: self.shortcut_mac,
|
||||
params: self.params,
|
||||
enabled: e.is_ok(),
|
||||
disabled_reason: e.err(),
|
||||
@@ -119,13 +145,13 @@ pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
macro_rules! cmd {
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: true }
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, shortcut_mac: None, params: $params, enabled: $en, run: $run, journal: true, undoable: true }
|
||||
};
|
||||
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, undoable: false }
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, shortcut_mac: None, params: $params, enabled: $en, run: $run, journal: false, undoable: false }
|
||||
};
|
||||
(noundo $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: false }
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, shortcut_mac: None, params: $params, enabled: $en, run: $run, journal: true, undoable: false }
|
||||
};
|
||||
}
|
||||
pub(crate) use cmd;
|
||||
@@ -140,6 +166,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(sheet::specs());
|
||||
v.extend(insert::specs());
|
||||
v.extend(controls::specs());
|
||||
v.extend(equation::specs());
|
||||
v.extend(data::specs());
|
||||
v.extend(formulas::specs());
|
||||
v.extend(review::specs());
|
||||
@@ -182,6 +209,7 @@ pub fn register_test_command(id: &'static str, run: Run) {
|
||||
label: id,
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
shortcut_mac: None,
|
||||
params: "",
|
||||
enabled: |_| Ok(()),
|
||||
run,
|
||||
@@ -199,6 +227,7 @@ pub(crate) fn register_undoable_test_command(id: &'static str, run: Run) {
|
||||
label: id,
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
shortcut_mac: None,
|
||||
params: "",
|
||||
enabled: |_| Ok(()),
|
||||
run,
|
||||
@@ -376,6 +405,9 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
};
|
||||
let r = f(&mut ctx)?;
|
||||
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, edits, .. } = ctx;
|
||||
// Equations whose source changed (or that arrived without a layout, as from a paste) are
|
||||
// laid out here, so painting only ever reads the cache.
|
||||
wb.refresh_equations();
|
||||
if !protection_checked {
|
||||
check_protection(&d.wb, &wb, &d.unlocked_ranges)?;
|
||||
}
|
||||
|
||||
@@ -99,18 +99,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(query "data.validate", "Check Value Against Validation", [], None, "{cell?, input} → {ok, message?}", has_doc, validate_cmd),
|
||||
cmd!(noundo "data.circleInvalid", "Circle Invalid Data", ["Data", "Data Tools", "Data Validation"], None, "{} → invalid cells", has_doc, circle_invalid),
|
||||
cmd!(
|
||||
"data.group",
|
||||
"Group",
|
||||
["Data", "Outline"],
|
||||
Some("Alt+Shift+Right"),
|
||||
"{rows?: \"2:5\" | cols?: \"B:C\" | by?: rows|cols (the selection's rows or columns; a block of cells groups rows by default), sheet?}",
|
||||
has_doc,
|
||||
|s, p| group(s, p, true)
|
||||
),
|
||||
cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Alt+Shift+Left"), "{rows? | cols? | by?: rows|cols, sheet?}", has_doc, |s, p| {
|
||||
group(s, p, false)
|
||||
}),
|
||||
CommandSpec {
|
||||
shortcut_mac: Some("Cmd+Shift+K"),
|
||||
..cmd!(
|
||||
"data.group",
|
||||
"Group",
|
||||
["Data", "Outline"],
|
||||
Some("Alt+Shift+Right"),
|
||||
"{rows?: \"2:5\" | cols?: \"B:C\" | by?: rows|cols (the selection's rows or columns; a block of cells groups rows by default), sheet?}",
|
||||
has_doc,
|
||||
|s, p| group(s, p, true)
|
||||
)
|
||||
},
|
||||
CommandSpec {
|
||||
shortcut_mac: Some("Cmd+Shift+J"),
|
||||
..cmd!(
|
||||
"data.ungroup",
|
||||
"Ungroup",
|
||||
["Data", "Outline"],
|
||||
Some("Alt+Shift+Left"),
|
||||
"{rows? | cols? | by?: rows|cols, sheet?}",
|
||||
has_doc,
|
||||
|s, p| { group(s, p, false) }
|
||||
)
|
||||
},
|
||||
cmd!(
|
||||
"data.hideDetail",
|
||||
"Hide Detail",
|
||||
|
||||
@@ -80,6 +80,7 @@ fn stroke(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let text = pts.iter().map(|q| format!("{:.1},{:.1}", q[0] - minx, q[1] - miny)).collect::<Vec<_>>().join(";");
|
||||
let id = d.wb.next_object_id();
|
||||
let shape = Shape {
|
||||
math: Default::default(),
|
||||
id,
|
||||
kind: ShapeKind::Ink,
|
||||
anchor: Anchor {
|
||||
|
||||
@@ -81,15 +81,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
paste
|
||||
),
|
||||
cmd!(
|
||||
"edit.pasteSpecial",
|
||||
"Paste Special…",
|
||||
["Home", "Clipboard"],
|
||||
Some("Ctrl+Cmd+V"),
|
||||
"{what: all|formulas|values|formats|comments|validation|allExceptBorders|columnWidths|formulasAndNumberFormats|valuesAndNumberFormats, operation?: none|add|subtract|multiply|divide, skipBlanks?, transpose?, link?}",
|
||||
has_clipboard,
|
||||
paste_special
|
||||
),
|
||||
CommandSpec {
|
||||
shortcut_mac: Some("Ctrl+Cmd+V"),
|
||||
..cmd!(
|
||||
"edit.pasteSpecial",
|
||||
"Paste Special…",
|
||||
["Home", "Clipboard"],
|
||||
Some("Ctrl+Alt+V"),
|
||||
"{what: all|formulas|values|formats|comments|validation|allExceptBorders|columnWidths|formulasAndNumberFormats|valuesAndNumberFormats, operation?: none|add|subtract|multiply|divide, skipBlanks?, transpose?, link?}",
|
||||
has_clipboard,
|
||||
paste_special
|
||||
)
|
||||
},
|
||||
cmd!(noundo "edit.clearClipboard", "Cancel Copy", [], Some("Escape"), "{}", has_doc, clear_clipboard),
|
||||
cmd!("edit.clearAll", "Clear All", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "all")),
|
||||
cmd!("edit.clearFormats", "Clear Formats", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "formats")),
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Equations (Insert › Symbols › Equation): floating shapes holding LaTeX source, laid out by
|
||||
//! `gridcraft-math` and drawn from the cached layout.
|
||||
//!
|
||||
//! An equation is drawn to fit its frame, so resizing the frame scales it. Inserting sizes the
|
||||
//! frame for the requested font size; editing keeps the current scale and resizes the frame to
|
||||
//! the new content.
|
||||
|
||||
use gridcraft_math::{MathError, MathList};
|
||||
use gridcraft_model::{Anchor, AnchorMode, Color, EquationCache, Shape, ShapeKind};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::{CommandSpec, EngineError, PROTECTED, Result, Session, bad, cell_param, cmd, edit, f64_param, has_doc, ok, str_param, u32_param};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"insert.equation",
|
||||
"Equation",
|
||||
["Insert", "Symbols"],
|
||||
None,
|
||||
"{latex: \"x^2\", cell?|at?: \"B2\", size?: 11 (points), color?: hex|\"auto\"}",
|
||||
has_doc,
|
||||
insert_equation
|
||||
),
|
||||
cmd!("equation.edit", "Edit Equation", [], None, "{id, latex}", has_doc, edit_equation),
|
||||
]
|
||||
}
|
||||
|
||||
/// Default equation font size, in points.
|
||||
pub const DEFAULT_SIZE_PT: f64 = 11.0;
|
||||
/// Space between an equation's frame and its content, in sheet pixels.
|
||||
pub const PADDING: f32 = 4.0;
|
||||
/// Sheet pixels per point.
|
||||
const PX_PER_PT: f64 = 96.0 / 72.0;
|
||||
|
||||
/// The scale (sheet pixels per em) at which `list` fits a frame of `w` by `h` pixels.
|
||||
pub fn fit_scale(list: &MathList, w: f32, h: f32) -> f32 {
|
||||
let (cw, ch) = (list.width.max(0.01), list.total_height().max(0.01));
|
||||
let s = ((w - 2.0 * PADDING) / cw).min((h - 2.0 * PADDING) / ch);
|
||||
if s.is_finite() { s.max(0.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// The frame size for `list` at `scale` pixels per em.
|
||||
fn frame_for(list: &MathList, scale: f32) -> (f32, f32) {
|
||||
let w = list.width.max(0.5) * scale + 2.0 * PADDING;
|
||||
let h = list.total_height().max(1.0) * scale + 2.0 * PADDING;
|
||||
(w.clamp(8.0, 20_000.0), h.clamp(8.0, 20_000.0))
|
||||
}
|
||||
|
||||
/// `latex` laid out, or a readable error with the position of the problem.
|
||||
fn lay_out(cmd: &str, latex: &str) -> Result<MathList> {
|
||||
gridcraft_math::layout(latex).map_err(|e| bad(cmd, describe(latex, &e)))
|
||||
}
|
||||
|
||||
/// "Error at character 5: Undefined control sequence: \foo".
|
||||
pub fn describe(latex: &str, e: &MathError) -> String {
|
||||
match e.char_pos(latex) {
|
||||
Some(p) => format!("equation error at character {p}: {}", e.message),
|
||||
None => format!("equation error: {}", e.message),
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_equation(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "insert.equation";
|
||||
let latex = str_param(p, "latex").ok_or_else(|| bad(CMD, "`latex` must be the equation's LaTeX source, such as \"x^2 + y^2 = r^2\""))?;
|
||||
if latex.trim().is_empty() {
|
||||
return Err(bad(CMD, "`latex` is empty"));
|
||||
}
|
||||
let size = f64_param(p, "size").unwrap_or(DEFAULT_SIZE_PT);
|
||||
if !(1.0..=409.0).contains(&size) {
|
||||
return Err(bad(CMD, "`size` must be between 1 and 409 points"));
|
||||
}
|
||||
let color = match p.get("color") {
|
||||
None | Some(Json::Null) => Color::Auto,
|
||||
Some(c) => super::format::color_param(Some(c)).ok_or_else(|| bad(CMD, "`color` must be a hex colour such as \"#1F4E79\", or \"auto\""))?,
|
||||
};
|
||||
let list = lay_out(CMD, latex)?;
|
||||
let at = match cell_param(s, p, "cell")? {
|
||||
Some(c) => Some(c),
|
||||
None => cell_param(s, p, "at")?,
|
||||
};
|
||||
let d = s.doc()?;
|
||||
if d.wb.active().is_some_and(|sh| sh.is_protected()) {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
}
|
||||
let at = at.unwrap_or(d.selection.active);
|
||||
let id = d.wb.next_object_id();
|
||||
let (width, height) = frame_for(&list, (size * PX_PER_PT) as f32);
|
||||
let mut shape = Shape {
|
||||
id,
|
||||
kind: ShapeKind::Equation,
|
||||
anchor: Anchor { cell: at, dx: 0.0, dy: 0.0, width, height, mode: AnchorMode::MoveOnly },
|
||||
fill: Color::Auto,
|
||||
line: color,
|
||||
text: latex.to_string(),
|
||||
format: Default::default(),
|
||||
math: Default::default(),
|
||||
};
|
||||
shape.math.refresh(latex);
|
||||
let sheet = d.wb.active_sheet();
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.shapes.push(shape);
|
||||
Ok(json!({"equation": id}))
|
||||
})
|
||||
}
|
||||
|
||||
fn edit_equation(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "equation.edit";
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad(CMD, "`id` must be an equation id"))?;
|
||||
let latex = str_param(p, "latex").ok_or_else(|| bad(CMD, "`latex` must be the equation's LaTeX source"))?;
|
||||
if latex.trim().is_empty() {
|
||||
return Err(bad(CMD, "`latex` is empty; delete the equation instead"));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sh = d.wb.active().ok_or_else(|| bad(CMD, "no such equation"))?;
|
||||
let shape = sh.shapes.iter().find(|x| x.id == id && x.kind == ShapeKind::Equation).ok_or_else(|| bad(CMD, "no such equation"))?;
|
||||
if sh.is_protected() {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
}
|
||||
// The same source is a no-op, unless it has no layout yet (a file with more equations than
|
||||
// are laid out at once): then OK lays it out.
|
||||
if shape.text == latex && matches!(shape.math.get(latex), Some(Ok(_))) {
|
||||
return ok();
|
||||
}
|
||||
let list = lay_out(CMD, latex)?;
|
||||
// Keep the size the equation is drawn at now; without a layout, use the default size.
|
||||
let scale = match shape.math.get(&shape.text) {
|
||||
Some(Ok(old)) => fit_scale(old, shape.anchor.width, shape.anchor.height),
|
||||
_ => 0.0,
|
||||
};
|
||||
let scale = if scale > 0.1 { scale } else { (DEFAULT_SIZE_PT * PX_PER_PT) as f32 };
|
||||
let (width, height) = frame_for(&list, scale);
|
||||
edit(s, |cx| {
|
||||
let shape = cx.sheet_mut(sheet)?.shapes.iter_mut().find(|x| x.id == id).ok_or_else(|| bad(CMD, "no such equation"))?;
|
||||
shape.text = latex.to_string();
|
||||
shape.math = EquationCache::default();
|
||||
shape.math.refresh(latex);
|
||||
shape.anchor.width = width;
|
||||
shape.anchor.height = height;
|
||||
Ok(json!({"equation": id}))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- PDF
|
||||
|
||||
/// The first [`gridcraft_math::MAX_SOURCE_BYTES`] bytes of an equation's source, cut at a char
|
||||
/// boundary. A longer source never lays out (the editor refuses it, and a file can carry one of
|
||||
/// megabytes), so what shows it as text, the sheet painter and the PDF fallback, shows this much
|
||||
/// and does no work on the rest.
|
||||
pub fn source_excerpt(text: &str) -> &str {
|
||||
let cap = gridcraft_math::MAX_SOURCE_BYTES;
|
||||
if text.len() <= cap {
|
||||
return text;
|
||||
}
|
||||
let mut end = cap;
|
||||
while end > 0 && !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
text.get(..end).unwrap_or("")
|
||||
}
|
||||
|
||||
/// The PDF face for a glyph: Symbol for Greek letters and operators, else the Times (or
|
||||
/// Helvetica, Courier) face for its style. `None` when no standard font can show `c`.
|
||||
fn pdf_font(c: char, face: gridcraft_math::Face) -> Option<gridcraft_pdf::Font> {
|
||||
use gridcraft_math::Face;
|
||||
use gridcraft_pdf::Font;
|
||||
let text = match face {
|
||||
Face::Italic => Font::TimesItalic,
|
||||
Face::Bold => Font::TimesBold,
|
||||
Face::BoldItalic => Font::TimesBoldItalic,
|
||||
Face::Sans => Font::Helvetica,
|
||||
Face::SansBold => Font::HelveticaBold,
|
||||
Face::SansItalic => Font::HelveticaOblique,
|
||||
Face::Mono => Font::Courier,
|
||||
Face::Roman | Face::Large(_) => Font::TimesRoman,
|
||||
};
|
||||
// Letters and digits stay in the text face; everything Symbol has goes to Symbol, whose
|
||||
// operators (minus, equals, plus) match the maths better than the text faces'.
|
||||
if c.is_ascii_alphanumeric() {
|
||||
return Some(text);
|
||||
}
|
||||
if Font::Symbol.can_encode(c) {
|
||||
return Some(Font::Symbol);
|
||||
}
|
||||
text.can_encode(c).then_some(text)
|
||||
}
|
||||
|
||||
/// Draws an equation's layout into the box `(x, y, w, h)` (page points) of a PDF page, with
|
||||
/// `pad` points of padding. Glyphs no standard font has are drawn as `[?]` boxes.
|
||||
pub(crate) fn draw_pdf(pg: &mut gridcraft_pdf::Page, list: &MathList, rect: (f32, f32, f32, f32), pad: f32, ink: gridcraft_pdf::Rgb) {
|
||||
use gridcraft_math::{Face, Item, PathCmd};
|
||||
let (x, y, w, h) = rect;
|
||||
let (cw, ch) = (list.width.max(0.01), list.total_height().max(0.01));
|
||||
let s = ((w - 2.0 * pad) / cw).min((h - 2.0 * pad) / ch);
|
||||
if !(s.is_finite() && s > 0.01) {
|
||||
return;
|
||||
}
|
||||
let s = s.min(4000.0);
|
||||
let (ox, oy) = (x + (w - cw * s) / 2.0, y + (h - ch * s) / 2.0);
|
||||
let col = |c: Option<[u8; 3]>| c.unwrap_or(ink);
|
||||
for item in list.items.iter().take(gridcraft_math::MAX_ITEMS) {
|
||||
match item {
|
||||
Item::Glyph(g) => {
|
||||
let color = col(g.color);
|
||||
let (gx, base) = (ox + g.x * s, oy + g.y * s);
|
||||
// The radical sign: Symbol's glyph (AFM bounding box -147 to 917 high, right edge
|
||||
// at 558, in 1/1000 em) fitted to the TeX box, so its top meets the vinculum.
|
||||
if g.ch == '\u{221A}' {
|
||||
let size = (g.height + g.depth) * s / 1.064;
|
||||
if size.is_finite() && size >= 0.5 {
|
||||
let top = base - g.height * s;
|
||||
pg.text(gx + g.width * s - 0.558 * size, top + 0.917 * size, size, gridcraft_pdf::Font::Symbol, color, "\u{221A}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let large = matches!(g.face, Face::Large(_));
|
||||
let size = if large { (g.height + g.depth) * s } else { g.size * s };
|
||||
if !(size.is_finite() && size >= 0.5) {
|
||||
continue;
|
||||
}
|
||||
let pick = pdf_font(g.ch, g.face).map(|f| (g.ch, f)).or_else(|| g.alt.and_then(|a| pdf_font(a, g.face).map(|f| (a, f))));
|
||||
match pick {
|
||||
Some((c, font)) => {
|
||||
let text = c.to_string();
|
||||
if large {
|
||||
// Centred on the glyph's box, like the screen.
|
||||
let mid = oy + (g.y - (g.height - g.depth) / 2.0) * s;
|
||||
let tw = gridcraft_pdf::text_width(font, size, &text);
|
||||
pg.text(gx + (g.width * s - tw) / 2.0, mid + 0.25 * size, size, font, color, &text);
|
||||
} else {
|
||||
pg.text(gx, base, size, font, color, &text);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// [?]: the glyph's box with a question mark.
|
||||
let (bw, bh) = ((g.width.max(0.3)) * s, (g.height + g.depth) * s);
|
||||
pg.rect_stroke(gx, base - g.height * s, bw, bh, (0.05 * s).clamp(0.2, 1.0), color);
|
||||
let qs = (bh * 0.6).min(bw * 1.2).max(1.0);
|
||||
let qw = gridcraft_pdf::text_width(gridcraft_pdf::Font::Helvetica, qs, "?");
|
||||
pg.text(gx + (bw - qw) / 2.0, base - g.height * s + (bh + qs * 0.7) / 2.0, qs, gridcraft_pdf::Font::Helvetica, color, "?");
|
||||
}
|
||||
}
|
||||
}
|
||||
Item::Rule(r) => {
|
||||
if r.width > 0.0 && r.height > 0.0 {
|
||||
pg.fill_rect(ox + r.x * s, oy + r.y * s, r.width * s, (r.height * s).max(0.25), col(r.color));
|
||||
}
|
||||
}
|
||||
Item::Path(p) => {
|
||||
let (px, py) = (ox + p.x * s, oy + p.y * s);
|
||||
let mut subs: Vec<Vec<[f32; 2]>> = vec![];
|
||||
let mut cur: Vec<[f32; 2]> = vec![];
|
||||
let at = |a: f32, b: f32| [px + a * s, py + b * s];
|
||||
for c in &p.commands {
|
||||
match *c {
|
||||
PathCmd::MoveTo(a, b) => {
|
||||
if cur.len() > 1 {
|
||||
subs.push(std::mem::take(&mut cur));
|
||||
}
|
||||
cur = vec![at(a, b)];
|
||||
}
|
||||
PathCmd::LineTo(a, b) => cur.push(at(a, b)),
|
||||
PathCmd::QuadTo(x1, y1, a, b) => {
|
||||
let p0 = cur.last().copied().unwrap_or(at(x1, y1));
|
||||
let (c1, p1) = (at(x1, y1), at(a, b));
|
||||
for k in 1..=8 {
|
||||
let t = k as f32 / 8.0;
|
||||
let u = 1.0 - t;
|
||||
cur.push([u * u * p0[0] + 2.0 * u * t * c1[0] + t * t * p1[0], u * u * p0[1] + 2.0 * u * t * c1[1] + t * t * p1[1]]);
|
||||
}
|
||||
}
|
||||
PathCmd::CubicTo(x1, y1, x2, y2, a, b) => {
|
||||
let p0 = cur.last().copied().unwrap_or(at(x1, y1));
|
||||
let (c1, c2, p1) = (at(x1, y1), at(x2, y2), at(a, b));
|
||||
for k in 1..=10 {
|
||||
let t = k as f32 / 10.0;
|
||||
let u = 1.0 - t;
|
||||
let (a3, b3, c3, d3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
|
||||
cur.push([a3 * p0[0] + b3 * c1[0] + c3 * c2[0] + d3 * p1[0], a3 * p0[1] + b3 * c1[1] + c3 * c2[1] + d3 * p1[1]]);
|
||||
}
|
||||
}
|
||||
PathCmd::Close => {
|
||||
if cur.len() > 1 {
|
||||
subs.push(std::mem::take(&mut cur));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cur.len() > 1 {
|
||||
subs.push(cur);
|
||||
}
|
||||
let color = col(p.color);
|
||||
for sub in subs.iter().filter(|v| v.len() <= 4000) {
|
||||
if p.fill && sub.len() >= 3 {
|
||||
// The PDF fill rule handles concave outlines itself.
|
||||
pg.polygon(sub, Some(color), None);
|
||||
} else {
|
||||
pg.polyline(sub, (0.05 * s).clamp(0.2, 1.0), color, &[]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{
|
||||
Anchor, Chart, Color, DefinedName, HAlign, LegendPos, NumFmt, Shape, Sheet, SheetViewMode, TableColumn, TotalsFn, Trendline, TrendlineKind,
|
||||
Workbook,
|
||||
Anchor, Chart, Color, DefinedName, HAlign, LegendPos, NumFmt, Shape, ShapeKind, Sheet, SheetViewMode, TableColumn, TotalsFn, Trendline,
|
||||
TrendlineKind, Workbook,
|
||||
};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
@@ -97,7 +97,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Bring Forward",
|
||||
["Page Layout", "Arrange"],
|
||||
None,
|
||||
"{kind: chart|image|shape|slicer|control, id, toFront?: bool}",
|
||||
"{kind: chart|image|shape|slicer|equation|control, id, toFront?: bool}",
|
||||
has_doc,
|
||||
|s, p| arrange(s, p, true)
|
||||
),
|
||||
@@ -106,7 +106,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Send Backward",
|
||||
["Page Layout", "Arrange"],
|
||||
None,
|
||||
"{kind: chart|image|shape|slicer|control, id, toBack?: bool}",
|
||||
"{kind: chart|image|shape|slicer|equation|control, id, toBack?: bool}",
|
||||
has_doc,
|
||||
|s, p| arrange(s, p, false)
|
||||
),
|
||||
@@ -116,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Align",
|
||||
["Page Layout", "Arrange"],
|
||||
None,
|
||||
"{kind: chart|image|shape|slicer|control, ids: [..], align: left|center|right|top|middle|bottom}",
|
||||
"{kind: chart|image|shape|slicer|equation|control, ids: [..], align: left|center|right|top|middle|bottom}",
|
||||
has_doc,
|
||||
align
|
||||
),
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
use gridcraft_model::ObjKind;
|
||||
|
||||
use super::chart::format_series;
|
||||
use super::{
|
||||
Anchor, CellRef, Color, EngineError, Json, Result, Session, Shape, Sheet, anchor_mode_name, bad, bool_param, edit, json, str_param, u32_param,
|
||||
Anchor, CellRef, Color, EngineError, Json, Result, Session, Shape, ShapeKind, Sheet, anchor_mode_name, bad, bool_param, edit, json, str_param,
|
||||
u32_param,
|
||||
};
|
||||
|
||||
/// The kind of object a `kind` parameter names: `shape` when the parameter is absent; one that
|
||||
/// is present but not one of the names (`null`, a number, an unknown word) is an error, as for
|
||||
/// the `object.*` commands, where the kind is required.
|
||||
fn kind_param(p: &Json, cmd: &str) -> Result<ObjKind> {
|
||||
let err = || bad(cmd, "kind must be chart, image, shape, slicer or equation");
|
||||
match p.get("kind") {
|
||||
None => Ok(ObjKind::Shape),
|
||||
Some(Json::String(k)) => ObjKind::parse(k).ok_or_else(err),
|
||||
Some(_) => Err(err()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `kind` names the shape `sh`: an equation is a shape too, and a plain shape is not an
|
||||
/// equation, as `Sheet::object_anchor` has it for the `object.*` commands.
|
||||
fn shape_is(kind: ObjKind, sh: &Shape) -> bool {
|
||||
kind != ObjKind::Equation || sh.kind == ShapeKind::Equation
|
||||
}
|
||||
|
||||
pub(super) fn shape_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if str_param(p, "kind") == Some("shape") {
|
||||
let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default();
|
||||
@@ -33,12 +54,10 @@ fn with_shape(s: &mut Session, p: &Json, f: impl FnOnce(&mut Shape)) -> Result<J
|
||||
|
||||
pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad("arrange", "missing `id`"))?;
|
||||
let kind = str_param(p, "kind").unwrap_or("shape").to_string();
|
||||
let kind = kind_param(p, "arrange")?;
|
||||
let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false);
|
||||
let si = s.doc()?.wb.active_sheet();
|
||||
if let Some(k) = gridcraft_model::ObjKind::parse(&kind) {
|
||||
crate::cmd::controls::check_objects_unlocked(s, si, k, &[id])?;
|
||||
}
|
||||
crate::cmd::controls::check_objects_unlocked(s, si, kind, &[id])?;
|
||||
fn reorder<T>(v: &mut Vec<T>, i: usize, forward: bool, extreme: bool) {
|
||||
if i >= v.len() {
|
||||
return;
|
||||
@@ -54,33 +73,18 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json>
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
match kind.as_str() {
|
||||
"chart" => {
|
||||
if let Some(i) = sh.charts.iter().position(|c| c.id == id) {
|
||||
reorder(&mut sh.charts, i, forward, extreme);
|
||||
}
|
||||
// An id that is not an object of this kind is an error, so a stale id, or a shape's id
|
||||
// given as an equation, never reorders something else or succeeds silently.
|
||||
let found = match kind {
|
||||
ObjKind::Chart => sh.charts.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.charts, i, forward, extreme)),
|
||||
ObjKind::Image => sh.images.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.images, i, forward, extreme)),
|
||||
ObjKind::Slicer => sh.slicers.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.slicers, i, forward, extreme)),
|
||||
ObjKind::Shape | ObjKind::Equation => {
|
||||
sh.shapes.iter().position(|c| c.id == id && shape_is(kind, c)).map(|i| reorder(&mut sh.shapes, i, forward, extreme))
|
||||
}
|
||||
"image" => {
|
||||
if let Some(i) = sh.images.iter().position(|c| c.id == id) {
|
||||
reorder(&mut sh.images, i, forward, extreme);
|
||||
}
|
||||
}
|
||||
"slicer" => {
|
||||
if let Some(i) = sh.slicers.iter().position(|c| c.id == id) {
|
||||
reorder(&mut sh.slicers, i, forward, extreme);
|
||||
}
|
||||
}
|
||||
"control" => {
|
||||
if let Some(i) = sh.controls.iter().position(|c| c.id == id) {
|
||||
reorder(&mut sh.controls, i, forward, extreme);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(i) = sh.shapes.iter().position(|c| c.id == id) {
|
||||
reorder(&mut sh.shapes, i, forward, extreme);
|
||||
}
|
||||
}
|
||||
}
|
||||
ObjKind::Control => sh.controls.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.controls, i, forward, extreme)),
|
||||
};
|
||||
found.ok_or_else(|| bad("arrange", "no such object"))?;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
@@ -90,7 +94,8 @@ pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let mut v = Vec::new();
|
||||
for c in &sh.shapes {
|
||||
v.push(json!({"kind": "shape", "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
|
||||
let kind = if c.kind == ShapeKind::Equation { "equation" } else { "shape" };
|
||||
v.push(json!({"kind": kind, "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
|
||||
}
|
||||
for c in &sh.images {
|
||||
v.push(json!({"kind": "image", "id": c.id, "name": format!("Picture {}", c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
|
||||
@@ -112,35 +117,45 @@ pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
Ok(Json::Array(v))
|
||||
}
|
||||
|
||||
fn anchors_of<'a>(sh: &'a mut Sheet, kind: &str) -> Vec<(u32, &'a mut Anchor)> {
|
||||
/// The anchors of every object of `kind` on the sheet; under `equation`, only the equations.
|
||||
fn anchors_of(sh: &mut Sheet, kind: ObjKind) -> Vec<(u32, &mut Anchor)> {
|
||||
match kind {
|
||||
"chart" => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
"image" => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
"slicer" => sh.slicers.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
"control" => sh.controls.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
_ => sh.shapes.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
ObjKind::Chart => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
ObjKind::Image => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
ObjKind::Slicer => sh.slicers.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
ObjKind::Control => sh.controls.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
ObjKind::Shape | ObjKind::Equation => sh.shapes.iter_mut().filter(|c| shape_is(kind, c)).map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn align(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let kind = str_param(p, "kind").unwrap_or("shape").to_string();
|
||||
let ids: Vec<u32> =
|
||||
p.get("ids").and_then(Json::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as u32)).collect()).unwrap_or_default();
|
||||
let kind = kind_param(p, "arrange.align")?;
|
||||
// `ids` may be absent (nothing to align) but, present, must be an array whose every entry is
|
||||
// an object id naming an object of the kind: a stale or mismatched id, or a value of another
|
||||
// shape, is an error, as for `arrange` and the `object.*` commands, not something silently
|
||||
// left out.
|
||||
let bad_ids = || bad("arrange.align", "ids must be an array of object ids");
|
||||
let ids: Vec<u32> = match p.get("ids") {
|
||||
None => Vec::new(),
|
||||
Some(Json::Array(a)) => a.iter().map(|v| v.as_u64().and_then(|x| u32::try_from(x).ok()).ok_or_else(bad_ids)).collect::<Result<_>>()?,
|
||||
Some(_) => return Err(bad_ids()),
|
||||
};
|
||||
let how = str_param(p, "align").unwrap_or("left").to_string();
|
||||
let si = s.doc()?.wb.active_sheet();
|
||||
if let Some(k) = gridcraft_model::ObjKind::parse(&kind) {
|
||||
crate::cmd::controls::check_objects_unlocked(s, si, k, &ids)?;
|
||||
}
|
||||
crate::cmd::controls::check_objects_unlocked(s, si, kind, &ids)?;
|
||||
edit(s, |cx| {
|
||||
let sheet = cx.sheet_mut(si)?;
|
||||
let geo: Vec<(u32, f64, f64, f32, f32)> = {
|
||||
let shc = sheet.clone();
|
||||
anchors_of(sheet, &kind)
|
||||
anchors_of(sheet, kind)
|
||||
.into_iter()
|
||||
.filter(|(id, _)| ids.contains(id))
|
||||
.map(|(id, a)| (id, shc.col_left(a.cell.col) + a.dx as f64, shc.row_top(a.cell.row) + a.dy as f64, a.width, a.height))
|
||||
.collect()
|
||||
};
|
||||
if let Some(missing) = ids.iter().find(|id| !geo.iter().any(|g| g.0 == **id)) {
|
||||
return Err(bad("arrange.align", format!("no such object: {missing}")));
|
||||
}
|
||||
if geo.len() < 2 {
|
||||
return Ok(Json::Null);
|
||||
}
|
||||
@@ -149,7 +164,7 @@ pub(super) fn align(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let top = geo.iter().map(|g| g.2).fold(f64::INFINITY, f64::min);
|
||||
let bottom = geo.iter().map(|g| g.2 + g.4 as f64).fold(f64::NEG_INFINITY, f64::max);
|
||||
let shc = sheet.clone();
|
||||
for (id, a) in anchors_of(sheet, &kind) {
|
||||
for (id, a) in anchors_of(sheet, kind) {
|
||||
let Some(g) = geo.iter().find(|g| g.0 == id) else { continue };
|
||||
let (mut x, mut y) = (g.1, g.2);
|
||||
match how.as_str() {
|
||||
|
||||
@@ -90,8 +90,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
format_cells
|
||||
),
|
||||
// Alignment
|
||||
cmd!("home.alignLeft", "Align Left", ["Home", "Alignment"], Some("Cmd+L"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Left)),
|
||||
cmd!("home.alignCenter", "Center", ["Home", "Alignment"], Some("Cmd+E"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Center)),
|
||||
CommandSpec {
|
||||
shortcut_mac: Some("Cmd+L"),
|
||||
..cmd!("home.alignLeft", "Align Left", ["Home", "Alignment"], Some("Cmd+L"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Left))
|
||||
},
|
||||
CommandSpec {
|
||||
shortcut_mac: Some("Cmd+E"),
|
||||
..cmd!("home.alignCenter", "Center", ["Home", "Alignment"], Some("Cmd+E"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Center))
|
||||
},
|
||||
cmd!("home.alignRight", "Align Right", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Right)),
|
||||
cmd!("home.justify", "Justify", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Justify)),
|
||||
cmd!("home.alignTop", "Top Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Top)),
|
||||
|
||||
@@ -146,13 +146,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
&with(p, "kind", json!("textBox"))
|
||||
)),
|
||||
cmd!("shape.setText", "Edit Text Box", [], None, "{id: textBox id, text: string}", has_doc, shape_set_text),
|
||||
cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer|control, id}", has_doc, delete_object),
|
||||
cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer|equation|control, id}", has_doc, delete_object),
|
||||
cmd!(
|
||||
"object.move",
|
||||
"Move Object",
|
||||
[],
|
||||
None,
|
||||
"{kind: chart|image|shape|slicer|control, id, at?: \"C3\", dx?, dy?, width?, height? (each at most 10,000 points, or the object's current size when a file made it larger)}",
|
||||
"{kind: chart|image|shape|slicer|equation|control, id, at?: \"C3\", dx?, dy?, width?, height? (each at most 10,000 points, or the object's current size when a file made it larger)}",
|
||||
has_doc,
|
||||
move_object
|
||||
),
|
||||
@@ -161,7 +161,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Object Properties",
|
||||
[],
|
||||
None,
|
||||
"{kind: chart|image|shape|slicer|control, id, mode: moveAndSize|moveOnly|absolute}",
|
||||
"{kind: chart|image|shape|slicer|equation|control, id, mode: moveAndSize|moveOnly|absolute}",
|
||||
has_doc,
|
||||
set_anchor_mode
|
||||
),
|
||||
@@ -1170,6 +1170,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Color::Theme(4, -250)
|
||||
});
|
||||
let shape = Shape {
|
||||
math: Default::default(),
|
||||
id,
|
||||
kind,
|
||||
anchor: Anchor {
|
||||
@@ -1227,7 +1228,8 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
/// objects are errors, so a stale id never succeeds silently.
|
||||
fn object_param(s: &Session, p: &Json, cmd: &str) -> Result<(usize, ObjRef)> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
|
||||
let kind = str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape, slicer or control"))?;
|
||||
let kind =
|
||||
str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape, slicer, equation or control"))?;
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet();
|
||||
let o = ObjRef::new(kind, id);
|
||||
@@ -1786,6 +1788,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
|
||||
let color = super::format::color_param(p.get("color")).unwrap_or(Color::Theme(1, 0));
|
||||
let shape = Shape {
|
||||
math: Default::default(),
|
||||
id,
|
||||
kind: ShapeKind::Icon,
|
||||
anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width: size, height: size, mode: anchor_mode_param(p).unwrap_or_default() },
|
||||
|
||||
@@ -333,6 +333,7 @@ pub(super) fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
line: Color::Auto,
|
||||
text: name,
|
||||
format: Default::default(),
|
||||
math: Default::default(),
|
||||
};
|
||||
let sheet = d.wb.active_sheet();
|
||||
edit(s, |cx| {
|
||||
|
||||
@@ -153,6 +153,7 @@ pub(super) fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
line,
|
||||
text: str_param(p, "text").unwrap_or("").into(),
|
||||
format: Default::default(),
|
||||
math: Default::default(),
|
||||
};
|
||||
let sheet = d.wb.active_sheet();
|
||||
edit(s, |cx| {
|
||||
|
||||
@@ -29,6 +29,26 @@ pub(super) fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32
|
||||
}
|
||||
|
||||
/// Sheet pixel rectangle of an anchor.
|
||||
/// Most source lines printed for an equation that has no layout.
|
||||
pub(super) const MAX_FALLBACK_LINES: usize = 500;
|
||||
|
||||
/// The lines of `text` printed in place of an equation that has no layout: those of its first
|
||||
/// `MAX_SOURCE_BYTES` (a longer source never lays out, and a file can carry megabytes) that fit a
|
||||
/// frame `h` points tall at `line_h` points per line (at least the first), and never more than
|
||||
/// [`MAX_FALLBACK_LINES`]. A single `pg.text` call would print every newline as `?`.
|
||||
pub(super) fn fallback_lines(text: &str, h: f32, line_h: f32) -> impl Iterator<Item = &str> {
|
||||
let text = crate::cmd::equation::source_excerpt(text);
|
||||
let fit = h / line_h;
|
||||
let n = if fit >= MAX_FALLBACK_LINES as f32 {
|
||||
MAX_FALLBACK_LINES
|
||||
} else if fit >= 1.0 {
|
||||
fit as usize
|
||||
} else {
|
||||
1
|
||||
};
|
||||
text.lines().take(n)
|
||||
}
|
||||
|
||||
fn anchor_rect(sh: &Sheet, a: &Anchor) -> (f64, f64, f64, f64) {
|
||||
(sh.col_left(a.cell.col) + a.dx as f64, sh.row_top(a.cell.row) + a.dy as f64, a.width.max(0.0) as f64, a.height.max(0.0) as f64)
|
||||
}
|
||||
@@ -79,6 +99,25 @@ pub(super) fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet,
|
||||
continue;
|
||||
}
|
||||
let (x, y, w, h) = place(r);
|
||||
if shp.kind == ShapeKind::Equation {
|
||||
let ink = rgb_of(shp.line, wb).unwrap_or([0, 0, 0]);
|
||||
match shp.math.get(&shp.text) {
|
||||
Some(Ok(list)) => crate::cmd::equation::draw_pdf(pg, list, (x, y, w, h), crate::cmd::equation::PADDING * b.k, ink),
|
||||
_ => {
|
||||
// No layout (the source does not parse, or the file was past the layout
|
||||
// budget): the source, one text run per line, as many as the frame holds.
|
||||
let line_h = 12.0 * b.scale;
|
||||
// A long line stays inside the frame; this clip nests in the block's.
|
||||
pg.save();
|
||||
pg.clip_rect(x, y, w, h);
|
||||
for (i, line) in fallback_lines(&shp.text, h, line_h).enumerate() {
|
||||
pg.text(x + 2.0 * b.k, y + line_h * (i + 1) as f32, 10.0 * b.scale, Font::Courier, ink, line);
|
||||
}
|
||||
pg.restore();
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
draw_shape(pg, wb, shp, b, (x, y, w, h));
|
||||
}
|
||||
for ch in &sh.charts {
|
||||
|
||||
@@ -329,6 +329,14 @@ fn first_and_even_pages_print_their_own_headers() {
|
||||
assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj"));
|
||||
}
|
||||
|
||||
/// The y position of the first `(text) Tj` in a PDF (PDF space: larger is higher on the page).
|
||||
fn text_y(pdf: &[u8], text: &str) -> Option<f32> {
|
||||
let s = String::from_utf8_lossy(pdf);
|
||||
let before = &s[..s.find(&format!("({text}) Tj"))?];
|
||||
let tm = before.rfind(" Tm")?;
|
||||
before[..tm].rsplit([' ', '\n']).next()?.parse().ok()
|
||||
}
|
||||
|
||||
/// The x position and font size of the first `(text) Tj` in a PDF.
|
||||
fn text_at(pdf: &[u8], text: &str) -> Option<(f32, f32)> {
|
||||
let s = String::from_utf8_lossy(pdf);
|
||||
@@ -741,3 +749,113 @@ fn checkbox_cells_print_as_boxes_not_true_false() {
|
||||
assert!(contains(&pdf, "0.063 0.486 0.255 rg"), "the checked box is not filled");
|
||||
assert!(contains(&pdf, "0.431 0.431 0.431 RG"), "the unchecked box is not outlined");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_equation_without_a_layout_prints_its_source_one_line_per_line() {
|
||||
use super::objects::{MAX_FALLBACK_LINES, fallback_lines};
|
||||
let mut s = s();
|
||||
s.execute("insert.equation", json!({"latex": "x", "cell": "B2"})).unwrap();
|
||||
// A file can carry a source the layout engine refuses: two lines here, in a frame 100 px
|
||||
// (75 pt) tall, which holds six 12 pt lines.
|
||||
let with_source = |text: &str| {
|
||||
let mut wb = (*s.doc().unwrap().wb).clone();
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
for x in sh.shapes.iter_mut() {
|
||||
x.text = text.into();
|
||||
x.anchor.height = 100.0;
|
||||
}
|
||||
}
|
||||
let mut t = Session::new();
|
||||
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
|
||||
pdf_of(&mut t, json!({})).0
|
||||
};
|
||||
let pdf = with_source("frac{\nsecond line");
|
||||
assert!(contains(&pdf, "(frac{) Tj"), "the first line is its own text run");
|
||||
assert!(contains(&pdf, "(second line) Tj"), "and so is the second");
|
||||
assert!(!contains(&pdf, "(frac{?second line) Tj"), "the newline is not printed as ?");
|
||||
let (y1, y2) = (text_y(&pdf, "frac{").unwrap(), text_y(&pdf, "second line").unwrap());
|
||||
assert!(y1 > y2 && y1 - y2 < 13.0, "the second line sits one line below the first: {y1} {y2}");
|
||||
// Lines the frame cannot hold are not printed.
|
||||
let pdf = with_source(&"l\n".repeat(10_000));
|
||||
assert_eq!(String::from_utf8_lossy(&pdf).matches("(l) Tj").count(), 6);
|
||||
// Whatever the frame, the count is capped; a frame too low for one line still shows the first.
|
||||
let tall = "l\n".repeat(10_000);
|
||||
assert_eq!(fallback_lines(&tall, f32::INFINITY, 12.0).count(), MAX_FALLBACK_LINES);
|
||||
assert_eq!(fallback_lines(&tall, 1.0e9, 12.0).count(), MAX_FALLBACK_LINES);
|
||||
assert_eq!(fallback_lines("a\nb\nc", 30.0, 12.0).count(), 2);
|
||||
assert_eq!(fallback_lines("a\nb", 0.0, 12.0).count(), 1);
|
||||
assert_eq!(fallback_lines("a\nb", f32::NAN, 12.0).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fallback_text_is_clipped_to_the_equation_frame() {
|
||||
// A long line of a source that does not lay out, in a narrow frame (40 by 100 px, 30 by
|
||||
// 75 pt): the lines are drawn inside a clip of the frame, nested in the block's, so they
|
||||
// cannot run over neighbours, and the clip is lifted again before the next object (a text
|
||||
// box below the equation in the same block) is drawn.
|
||||
let mut s = s();
|
||||
s.execute("insert.equation", json!({"latex": "x", "cell": "B2"})).unwrap();
|
||||
s.execute("insert.textBox", json!({"at": "B12", "text": "later box"})).unwrap();
|
||||
let mut wb = (*s.doc().unwrap().wb).clone();
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
for x in sh.shapes.iter_mut().filter(|x| x.kind == gridcraft_model::ShapeKind::Equation) {
|
||||
x.text = format!("frac{{{}", "l".repeat(200));
|
||||
x.anchor.width = 40.0;
|
||||
x.anchor.height = 100.0;
|
||||
}
|
||||
}
|
||||
let mut t = Session::new();
|
||||
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
|
||||
let (pdf, _) = pdf_of(&mut t, json!({}));
|
||||
let text = String::from_utf8_lossy(&pdf);
|
||||
let clip = text.find("30 75 re W n").expect("a clip the size of the frame");
|
||||
let run = text.find("(frac{llll").expect("the source line");
|
||||
let later = text.find("(later box) Tj").expect("the text box after the equation");
|
||||
assert!(clip < run && run < later, "clip, then the text, then the later object");
|
||||
// The net number of saved graphics states (`q` less `Q`) before an operator.
|
||||
let depth = |at: usize| text[..at].lines().filter(|l| *l == "q").count() as i64 - text[..at].lines().filter(|l| *l == "Q").count() as i64;
|
||||
assert_eq!(depth(run), depth(clip), "the text is drawn under the frame's clip");
|
||||
assert_eq!(depth(later), depth(clip) - 1, "the later object is drawn with the frame's state restored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::disallowed_methods)] // tests run only off wasm, where the clock exists
|
||||
fn an_equation_source_of_a_megabyte_prints_a_bounded_excerpt() {
|
||||
// A file can carry a source far past `MAX_SOURCE_BYTES` (the drawing part may be 512 MiB). It
|
||||
// never lays out, so the fallback prints the first 4,096 bytes and does no work on the rest:
|
||||
// the PDF holds one run of that length, not the megabyte, and export takes bounded time.
|
||||
let mut s = s();
|
||||
s.execute("insert.equation", json!({"latex": "x", "cell": "B2"})).unwrap();
|
||||
let mut wb = (*s.doc().unwrap().wb).clone();
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
for x in sh.shapes.iter_mut() {
|
||||
x.text = "l".repeat(1 << 20);
|
||||
}
|
||||
}
|
||||
let mut t = Session::new();
|
||||
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
|
||||
let start = std::time::Instant::now();
|
||||
let (pdf, _) = pdf_of(&mut t, json!({}));
|
||||
let took = start.elapsed();
|
||||
let text = String::from_utf8_lossy(&pdf);
|
||||
let longest = text.split(|c| c != 'l').map(str::len).max().unwrap_or(0);
|
||||
assert_eq!(longest, gridcraft_math::MAX_SOURCE_BYTES, "the run is the excerpt and no more");
|
||||
assert!(pdf.len() < 64 * 1024, "the PDF holds the excerpt, not the megabyte: {} bytes", pdf.len());
|
||||
assert!(took < std::time::Duration::from_secs(5), "{took:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equations_print_with_the_symbol_font() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap();
|
||||
s.execute("insert.equation", json!({"latex": "\\sum_{i=1}^{n} \\alpha_i \\le \\infty \\pm \\sqrt{x} \\times \\partial y", "cell": "B2"}))
|
||||
.unwrap();
|
||||
s.execute("insert.equation", json!({"latex": "\\mathbb{R} \\oplus \\text{\u{4E2D}}", "cell": "B8"})).unwrap();
|
||||
let (pdf, _) = pdf_of(&mut s, json!({}));
|
||||
assert!(contains(&pdf, "/BaseFont /Symbol >>"), "maths symbols use the Symbol font");
|
||||
assert!(contains(&pdf, "/BaseFont /Times-Italic"), "variables are italic");
|
||||
// The summation (0xE5) and alpha (0x61) codes in Symbol.
|
||||
assert!(contains(&pdf, "(\\345) Tj") && contains(&pdf, "(a) Tj"));
|
||||
// A glyph no standard font has is a [?] box.
|
||||
assert!(contains(&pdf, "(?) Tj"));
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Allow Edit Ranges…",
|
||||
menu: &["Review", "Protect"],
|
||||
shortcut: None,
|
||||
shortcut_mac: None,
|
||||
params: "{action?: list (default) | add | modify | delete | unlock, title?, range?, password?, newTitle?, sheet?} → ranges",
|
||||
enabled: has_doc,
|
||||
run: allow_edit_ranges,
|
||||
|
||||
@@ -39,6 +39,7 @@ pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command, register_te
|
||||
pub use gridcraft_calc as calc;
|
||||
pub use gridcraft_core as core;
|
||||
pub use gridcraft_formula as formula;
|
||||
pub use gridcraft_math as math;
|
||||
pub use gridcraft_model as model;
|
||||
pub use gridcraft_numfmt as numfmt;
|
||||
pub use selection::Selection;
|
||||
@@ -304,7 +305,9 @@ impl DocState {
|
||||
calc.rebuild(&wb);
|
||||
Self::with_calc(wb, calc, path, title, trust)
|
||||
}
|
||||
fn with_calc(wb: Workbook, calc: Calc, path: Option<String>, title: String, trust: Trust) -> DocState {
|
||||
fn with_calc(mut wb: Workbook, calc: Calc, path: Option<String>, title: String, trust: Trust) -> DocState {
|
||||
// Equations arrive without a layout (files never store one); painting reads only the cache.
|
||||
wb.refresh_equations();
|
||||
let scripts_trusted = !cmd::extra::has_scripts(&wb);
|
||||
let wb = Arc::new(wb);
|
||||
let sel = Selection::at(wb.active().map(|s| s.view_active).unwrap_or_default());
|
||||
|
||||
@@ -5244,6 +5244,7 @@ fn a_script_cannot_switch_windows() {
|
||||
}
|
||||
|
||||
mod controls;
|
||||
mod equation;
|
||||
mod macros;
|
||||
mod objects;
|
||||
mod seams;
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Equations: insert, edit, the layout cache and its rebuild on load.
|
||||
|
||||
use gridcraft_model::{Color, Shape, ShapeKind};
|
||||
use serde_json::json;
|
||||
|
||||
use super::s;
|
||||
use crate::Session;
|
||||
|
||||
fn equation(s: &Session, id: u32) -> Shape {
|
||||
s.doc().unwrap().wb.active().unwrap().shapes.iter().find(|x| x.id == id).cloned().unwrap()
|
||||
}
|
||||
|
||||
fn insert(s: &mut Session, p: serde_json::Value) -> u32 {
|
||||
s.execute("insert.equation", p).unwrap()["equation"].as_u64().unwrap() as u32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_lays_the_equation_out_and_sizes_its_frame() {
|
||||
let mut s = s();
|
||||
let id = insert(&mut s, json!({"latex": "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}", "cell": "C3"}));
|
||||
let e = equation(&s, id);
|
||||
assert_eq!(e.kind, ShapeKind::Equation);
|
||||
assert_eq!(e.anchor.cell.a1(), "C3");
|
||||
assert_eq!(e.line, Color::Auto, "Automatic ink");
|
||||
let list = e.math.get(&e.text).unwrap().unwrap();
|
||||
let scale = crate::cmd::equation::fit_scale(list, e.anchor.width, e.anchor.height);
|
||||
assert!((scale - 11.0 * 96.0 / 72.0).abs() < 0.01, "11 pt: {scale}");
|
||||
// A bigger size makes a bigger frame; `at` works like `cell`.
|
||||
let big = insert(&mut s, json!({"latex": "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}", "at": "H3", "size": 22, "color": "#1F4E79"}));
|
||||
let b = equation(&s, big);
|
||||
assert!((b.anchor.width - 8.0) > 1.9 * (e.anchor.width - 8.0));
|
||||
assert_eq!(b.line, Color::Rgb(0x1F4E79));
|
||||
assert_eq!(b.anchor.cell.a1(), "H3");
|
||||
// One undo step each.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().shapes.iter().all(|x| x.id != big));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_refuses_missing_bad_or_hostile_source() {
|
||||
let mut s = s();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"latex": ""}),
|
||||
json!({"latex": " "}),
|
||||
json!({"latex": 5}),
|
||||
json!({"latex": "x", "size": 0}),
|
||||
json!({"latex": "x", "size": f64::MAX}),
|
||||
json!({"latex": "x", "color": "nope"}),
|
||||
] {
|
||||
assert!(s.execute("insert.equation", p.clone()).is_err(), "{p}");
|
||||
}
|
||||
let e = s.execute("insert.equation", json!({"latex": "a + \\foo"})).unwrap_err().to_string();
|
||||
assert!(e.contains("character 5") && e.contains("\\foo"), "{e}");
|
||||
let e = s.execute("insert.equation", json!({"latex": "x".repeat(5000)})).unwrap_err().to_string();
|
||||
assert!(e.contains("4096"), "{e}");
|
||||
let e = s.execute("insert.equation", json!({"latex": format!("{}x{}", "{".repeat(40), "}".repeat(40))})).unwrap_err().to_string();
|
||||
assert!(e.contains("nest"), "{e}");
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().shapes.is_empty());
|
||||
// `equation.edit` is the other way a source reaches the layout engine; it has the same caps,
|
||||
// and a refused edit leaves the equation as it was.
|
||||
let id = insert(&mut s, json!({"latex": "x"}));
|
||||
let e = s.execute("equation.edit", json!({"id": id, "latex": "x".repeat(5000)})).unwrap_err().to_string();
|
||||
assert!(e.contains("4096"), "{e}");
|
||||
let e = s.execute("equation.edit", json!({"id": id, "latex": format!("{}x{}", "{".repeat(40), "}".repeat(40))})).unwrap_err().to_string();
|
||||
assert!(e.contains("nest"), "{e}");
|
||||
assert_eq!(equation(&s, id).text, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_keeps_the_scale_and_fits_the_frame() {
|
||||
let mut s = s();
|
||||
let id = insert(&mut s, json!({"latex": "x", "size": 20}));
|
||||
let before = equation(&s, id);
|
||||
s.execute("equation.edit", json!({"id": id, "latex": "x + y + z"})).unwrap();
|
||||
let after = equation(&s, id);
|
||||
assert_eq!(after.text, "x + y + z");
|
||||
let list = after.math.get(&after.text).unwrap().unwrap();
|
||||
let scale = crate::cmd::equation::fit_scale(list, after.anchor.width, after.anchor.height);
|
||||
assert!((scale - 20.0 * 96.0 / 72.0).abs() < 0.05, "{scale}");
|
||||
assert!(after.anchor.width > before.anchor.width);
|
||||
// A frame the user resized keeps its new scale through an edit.
|
||||
s.execute("object.move", json!({"kind": "equation", "id": id, "width": after.anchor.width * 2.0, "height": after.anchor.height * 2.0})).unwrap();
|
||||
s.execute("equation.edit", json!({"id": id, "latex": "x + y"})).unwrap();
|
||||
let e = equation(&s, id);
|
||||
let scale = crate::cmd::equation::fit_scale(e.math.get(&e.text).unwrap().unwrap(), e.anchor.width, e.anchor.height);
|
||||
assert!(scale > 2.0 * 20.0 * 96.0 / 72.0 * 0.95, "{scale}");
|
||||
// Undo restores the source and its layout.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let e = equation(&s, id);
|
||||
assert_eq!(e.text, "x + y + z");
|
||||
assert!(e.math.get(&e.text).unwrap().is_ok());
|
||||
// Same source: nothing to do. Bad source: refused, nothing changes.
|
||||
let depth = s.doc().unwrap().undo.len();
|
||||
s.execute("equation.edit", json!({"id": id, "latex": "x + y + z"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().undo.len(), depth);
|
||||
assert!(s.execute("equation.edit", json!({"id": id, "latex": "\\frac{"})).is_err());
|
||||
assert!(s.execute("equation.edit", json!({"id": id, "latex": ""})).is_err());
|
||||
assert_eq!(equation(&s, id).text, "x + y + z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_source_excerpt_is_at_most_the_cap_and_whole_chars() {
|
||||
use crate::cmd::equation::source_excerpt;
|
||||
use gridcraft_math::MAX_SOURCE_BYTES as CAP;
|
||||
assert_eq!(source_excerpt(""), "");
|
||||
assert_eq!(source_excerpt("x+y"), "x+y");
|
||||
let at = "x".repeat(CAP);
|
||||
assert_eq!(source_excerpt(&at), at, "a source at the cap is whole");
|
||||
assert_eq!(source_excerpt(&format!("{at}tail")), at);
|
||||
// A three-byte character straddling the cap is left out rather than cut in two.
|
||||
let s = format!("{}\u{20AC}", "x".repeat(CAP - 1));
|
||||
assert_eq!(source_excerpt(&s), "x".repeat(CAP - 1));
|
||||
let s = format!("{}\u{20AC}\u{20AC}", "x".repeat(CAP - 2));
|
||||
assert_eq!(source_excerpt(&s).len(), CAP - 2);
|
||||
assert_eq!(source_excerpt(&"\u{1F600}".repeat(2000)).len(), CAP);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_refuses_what_is_not_an_equation() {
|
||||
let mut s = s();
|
||||
let tb = s.execute("insert.textBox", json!({"text": "hi"})).unwrap()["shape"].as_u64().unwrap();
|
||||
assert!(s.execute("equation.edit", json!({"id": tb, "latex": "x"})).is_err());
|
||||
assert!(s.execute("equation.edit", json!({"id": 999, "latex": "x"})).is_err());
|
||||
assert!(s.execute("equation.edit", json!({"latex": "x"})).is_err());
|
||||
assert!(s.execute("equation.edit", json!({"id": -1, "latex": "x"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_are_rebuilt_on_load_and_not_saved() {
|
||||
let mut s = s();
|
||||
let id = insert(&mut s, json!({"latex": "e^{i\\pi} + 1 = 0"}));
|
||||
let wb = s.doc().unwrap().wb.clone();
|
||||
let bytes = serde_json::to_vec(&*wb).unwrap();
|
||||
assert!(!String::from_utf8_lossy(&bytes).contains("\"math\""), "the cache is not saved");
|
||||
let (opened, _) = crate::io::open_bytes("book.json", &bytes).unwrap();
|
||||
let raw = opened.active().unwrap().shapes.iter().find(|x| x.id == id).cloned().unwrap();
|
||||
assert!(raw.math.get(&raw.text).is_none(), "a freshly read shape has no layout");
|
||||
let mut t = Session::new();
|
||||
t.add_document(crate::DocState::new(opened, None, "Book".into(), crate::Trust::FromFile));
|
||||
let e = equation(&t, id);
|
||||
assert!(e.math.get(&e.text).unwrap().is_ok(), "opening lays it out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_source_from_a_file_keeps_its_error() {
|
||||
let mut s = s();
|
||||
let id = insert(&mut s, json!({"latex": "x"}));
|
||||
let mut wb = (*s.doc().unwrap().wb).clone();
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.shapes.iter_mut().for_each(|x| x.text = "\\frac{".into());
|
||||
}
|
||||
let mut t = Session::new();
|
||||
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
|
||||
let e = equation(&t, id);
|
||||
assert!(e.math.get(&e.text).unwrap().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equations_on_a_protected_sheet_are_locked() {
|
||||
let mut s = s();
|
||||
let id = insert(&mut s, json!({"latex": "x"}));
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
assert!(s.execute("equation.edit", json!({"id": id, "latex": "y"})).is_err());
|
||||
assert!(s.execute("insert.equation", json!({"latex": "y"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_too_many_equations_lays_out_a_bounded_amount() {
|
||||
// About 1 KB each: past the budget, equations are refused without running the layout engine,
|
||||
// so opening the file takes bounded time.
|
||||
let source = format!("x{}", "+x".repeat(500));
|
||||
let count = gridcraft_model::MAX_EQUATION_LAYOUT_BYTES / source.len() + 40;
|
||||
let mut s = s();
|
||||
let first = insert(&mut s, json!({"latex": "x", "cell": "A1"}));
|
||||
let mut wb = (*s.doc().unwrap().wb).clone();
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
let template = sh.shapes.iter().find(|x| x.id == first).cloned().unwrap();
|
||||
sh.shapes.clear();
|
||||
for i in 0..count {
|
||||
let mut e = template.clone();
|
||||
e.id = 100 + i as u32;
|
||||
e.text = source.clone();
|
||||
e.math = Default::default();
|
||||
sh.shapes.push(e);
|
||||
}
|
||||
}
|
||||
let mut t = Session::new();
|
||||
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
|
||||
let shapes = t.doc().unwrap().wb.active().unwrap().shapes.clone();
|
||||
let laid = shapes.iter().filter(|x| matches!(x.math.get(&x.text), Some(Ok(_)))).count();
|
||||
let refused: Vec<&Shape> =
|
||||
shapes.iter().filter(|x| matches!(x.math.get(&x.text), Some(Err(e)) if e.message == gridcraft_model::EQUATION_BUDGET_MESSAGE)).collect();
|
||||
assert_eq!(laid + refused.len(), count, "every equation has a layout or the budget's error");
|
||||
assert!(laid * source.len() <= gridcraft_model::MAX_EQUATION_LAYOUT_BYTES, "{laid} laid out");
|
||||
assert!(refused.len() >= 40, "{} refused", refused.len());
|
||||
// A later edit does not retry them all, and OK on one, unchanged, lays it out.
|
||||
let id = refused[0].id;
|
||||
t.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
|
||||
assert!(matches!(equation(&t, id).math.get(&source), Some(Err(_))));
|
||||
t.execute("equation.edit", json!({"id": id, "latex": source})).unwrap();
|
||||
assert!(matches!(equation(&t, id).math.get(&source), Some(Ok(_))), "editing lays it out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_stay_unique_when_a_file_used_the_largest_id() {
|
||||
// A workbook (from a JSON file, say) whose shape has id u32::MAX: new equations and the
|
||||
// objects of a copied sheet still get ids no other object has.
|
||||
let mut s = s();
|
||||
let first = insert(&mut s, json!({"latex": "x", "cell": "A1"}));
|
||||
let mut wb = (*s.doc().unwrap().wb).clone();
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.shapes.iter_mut().filter(|x| x.id == first).for_each(|x| x.id = u32::MAX);
|
||||
}
|
||||
let mut t = Session::new();
|
||||
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
|
||||
let a = insert(&mut t, json!({"latex": "y", "cell": "C1"}));
|
||||
let b = insert(&mut t, json!({"latex": "z", "cell": "E1"}));
|
||||
t.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap();
|
||||
let wb = &t.doc().unwrap().wb;
|
||||
let mut ids: Vec<u32> = wb.sheets.iter().flat_map(|sh| sh.shapes.iter().map(|x| x.id)).collect();
|
||||
assert_eq!(ids.len(), 6, "three equations on each sheet");
|
||||
assert!(a != u32::MAX && b != u32::MAX && a != b, "{a} {b}");
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), 6, "every id is distinct: {ids:?}");
|
||||
}
|
||||
@@ -20,8 +20,8 @@ fn id_of(v: &serde_json::Value, key: &str) -> u32 {
|
||||
}
|
||||
|
||||
/// A table with one object of each kind on the first sheet: a chart, a picture, a shape, a
|
||||
/// slicer and a form control, in `ObjKind::ALL` order.
|
||||
fn every_kind(s: &mut Session) -> [ObjRef; 5] {
|
||||
/// slicer, an equation and a form control, in `ObjKind::ALL` order.
|
||||
fn every_kind(s: &mut Session) -> [ObjRef; 6] {
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Qty"], ["East", 3], ["West", 5], ["East", 2], ["North", 8]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "A1:B5", "name": "Sales"})).unwrap();
|
||||
let chart = id_of(&s.execute("insert.chart", json!({"range": "B1:B5", "type": "column", "at": "D2"})).unwrap(), "chart");
|
||||
@@ -29,12 +29,14 @@ fn every_kind(s: &mut Session) -> [ObjRef; 5] {
|
||||
let shape = id_of(&s.execute("insert.shape", json!({"kind": "ellipse", "at": "H18"})).unwrap(), "shape");
|
||||
s.execute("selection.set", json!({"cell": "A2"})).unwrap();
|
||||
let slicer = id_of(&s.execute("insert.slicer", json!({"column": "Region", "at": "L2"})).unwrap(), "slicer");
|
||||
let equation = id_of(&s.execute("insert.equation", json!({"latex": "x^2", "cell": "L18"})).unwrap(), "equation");
|
||||
let control = id_of(&s.execute("insert.slider", json!({"at": "P18", "link": "$B$7"})).unwrap(), "control");
|
||||
[
|
||||
ObjRef::new(ObjKind::Chart, chart),
|
||||
ObjRef::new(ObjKind::Image, image),
|
||||
ObjRef::new(ObjKind::Shape, shape),
|
||||
ObjRef::new(ObjKind::Slicer, slicer),
|
||||
ObjRef::new(ObjKind::Equation, equation),
|
||||
ObjRef::new(ObjKind::Control, control),
|
||||
]
|
||||
}
|
||||
@@ -52,6 +54,9 @@ fn every_kind_is_found_moved_and_deleted_by_kind_and_id() {
|
||||
s.execute("object.setAnchorMode", json!({"kind": o.kind.name(), "id": o.id, "mode": "absolute"})).unwrap();
|
||||
assert_eq!(sheet(&s, 0).object_anchor(o).unwrap().mode, AnchorMode::Absolute);
|
||||
}
|
||||
// An equation is a shape too; a plain shape is not an equation.
|
||||
assert!(sheet(&s, 0).has_object(ObjRef::new(ObjKind::Shape, objs[4].id)));
|
||||
assert!(s.execute("object.delete", json!({"kind": "equation", "id": objs[2].id})).is_err());
|
||||
// A kind that does not match the id, an unknown id or kind, and a missing kind are refused.
|
||||
assert!(s.execute("object.delete", json!({"kind": "slicer", "id": objs[0].id})).is_err());
|
||||
assert!(s.execute("object.delete", json!({"kind": "chart", "id": 9999})).is_err());
|
||||
@@ -65,9 +70,9 @@ fn every_kind_is_found_moved_and_deleted_by_kind_and_id() {
|
||||
assert!(sheet(&s, 0).has_object(*later), "deleting {o:?} leaves {later:?}");
|
||||
}
|
||||
}
|
||||
// One undo step per delete brings the control back.
|
||||
// One undo step per delete brings the control, deleted last, back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(sheet(&s, 0).has_object(objs[4]));
|
||||
assert!(sheet(&s, 0).has_object(objs[5]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -85,7 +90,7 @@ fn selection_pane_lists_and_stacks_slicers() {
|
||||
assert_eq!(kinds.get(kinds.len() - 2), Some(&"slicer"), "slicers stack above charts, pictures and shapes");
|
||||
let names: Vec<&str> = items.iter().map(|o| o["name"].as_str().unwrap()).collect();
|
||||
assert!(names.contains(&"Region") && names.contains(&"Qty"), "{names:?}");
|
||||
assert!(names.contains(&format!("Slider {}", objs[4].id).as_str()), "{names:?}");
|
||||
assert!(names.contains(&format!("Slider {}", objs[5].id).as_str()), "{names:?}");
|
||||
assert_eq!(sheet(&s, 0).slicers.first().map(|c| c.id), Some(objs[3].id));
|
||||
s.execute("arrange.sendBackward", json!({"kind": "slicer", "id": second, "toBack": true})).unwrap();
|
||||
assert_eq!(sheet(&s, 0).slicers.first().map(|c| c.id), Some(second));
|
||||
@@ -121,17 +126,17 @@ fn cell_of(s: &Session, o: ObjRef) -> String {
|
||||
fn every_kind_follows_row_and_column_edits_and_sorts() {
|
||||
let mut s = s();
|
||||
let objs = every_kind(&mut s);
|
||||
// Anchors: chart D2, picture D18, shape H18, slicer L2, slider P18.
|
||||
// Anchors: chart D2, picture D18, shape H18, slicer L2, equation L18, slider P18.
|
||||
s.execute("object.setAnchorMode", json!({"kind": "shape", "id": objs[2].id, "mode": "absolute"})).unwrap();
|
||||
let cells = |s: &Session| objs.map(|o| cell_of(s, o));
|
||||
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "P18"]);
|
||||
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18", "P18"]);
|
||||
s.execute("home.insertRows", json!({"rows": "10:11"})).unwrap();
|
||||
assert_eq!(cells(&s), ["D2", "D20", "H18", "L2", "P20"], "rows inserted above move the objects below them; an absolute shape stays");
|
||||
assert_eq!(cells(&s), ["D2", "D20", "H18", "L2", "L20", "P20"], "rows inserted above move the objects below them; an absolute shape stays");
|
||||
s.execute("home.insertColumns", json!({"cols": "C:C"})).unwrap();
|
||||
assert_eq!(cells(&s), ["E2", "E20", "H18", "M2", "Q20"]);
|
||||
assert_eq!(cells(&s), ["E2", "E20", "H18", "M2", "M20", "Q20"]);
|
||||
s.execute("home.deleteColumns", json!({"cols": "C:C"})).unwrap();
|
||||
s.execute("home.deleteRows", json!({"rows": "10:11"})).unwrap();
|
||||
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "P18"]);
|
||||
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18", "P18"]);
|
||||
// Sorting moves a picture anchored inside the sorted block with its row.
|
||||
s.execute("range.setValues", json!({"range": "T1", "values": [["k"], [3], [1], [2]]})).unwrap();
|
||||
let pic = ObjRef::new(ObjKind::Image, super::add_image(&mut s, "T2", AnchorMode::MoveAndSize));
|
||||
@@ -269,9 +274,11 @@ fn a_copied_sheet_gives_its_objects_fresh_ids() {
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), 2 * objs.len(), "every object id stays unique across the workbook");
|
||||
let copy = sheet(&s, 1);
|
||||
for (o, id) in objs.iter().zip(ids_of(copy)) {
|
||||
assert_eq!(copy.object_ref(id).map(|c| c.kind), Some(o.kind));
|
||||
}
|
||||
let mut kinds: Vec<&str> = ids_of(copy).into_iter().filter_map(|id| copy.object_ref(id)).map(|o| o.kind.name()).collect();
|
||||
let mut want: Vec<&str> = objs.iter().map(|o| o.kind.name()).collect();
|
||||
kinds.sort_unstable();
|
||||
want.sort_unstable();
|
||||
assert_eq!(kinds, want, "the copies keep their kinds");
|
||||
assert!(s.doc().unwrap().wb.next_object_id() > *ids.last().unwrap_or(&0));
|
||||
}
|
||||
|
||||
@@ -389,3 +396,106 @@ fn chart_set_keeps_a_chart_size_a_file_made_larger_than_the_limit() {
|
||||
s.execute("chart.set", json!({"chart": chart, "width": 50_000, "height": 50_000})).unwrap();
|
||||
assert_eq!(size(&s), (11_990.0, 10_000.0), "hostile sizes are capped at the current width and at 10,000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrange_and_align_match_a_shape_id_to_its_kind() {
|
||||
// `arrange.*` and `arrange.align` under `kind: "equation"` act on equations only: an
|
||||
// ordinary shape's id given as an equation is refused by `arrange` and left in place by
|
||||
// `align`, as `object.delete` refuses it. An equation is a shape too, so its id works under
|
||||
// `shape`, as for the other object commands; an unknown kind, and an id of no object of the
|
||||
// kind, are refused instead of acting on whatever shape has the id.
|
||||
let mut s = s();
|
||||
let objs = every_kind(&mut s);
|
||||
let (shape, equation) = (objs[2], objs[4]);
|
||||
let order = |s: &Session| -> Vec<u32> { sheet(s, 0).shapes.iter().map(|c| c.id).collect() };
|
||||
assert_eq!(order(&s), vec![shape.id, equation.id]);
|
||||
assert!(s.execute("arrange.bringForward", json!({"kind": "equation", "id": shape.id, "toFront": true})).is_err());
|
||||
assert_eq!(order(&s), vec![shape.id, equation.id], "the shape is not reordered as an equation");
|
||||
s.execute("arrange.sendBackward", json!({"kind": "equation", "id": equation.id, "toBack": true})).unwrap();
|
||||
assert_eq!(order(&s), vec![equation.id, shape.id]);
|
||||
s.execute("arrange.bringForward", json!({"kind": "shape", "id": equation.id, "toFront": true})).unwrap();
|
||||
assert_eq!(order(&s), vec![shape.id, equation.id], "an equation is a shape too");
|
||||
assert!(s.execute("arrange.bringForward", json!({"kind": "widget", "id": shape.id})).is_err());
|
||||
assert!(s.execute("arrange.bringForward", json!({"kind": "chart", "id": 9999})).is_err());
|
||||
assert!(s.execute("arrange.bringForward", json!({"kind": "shape", "id": objs[0].id})).is_err(), "a chart's id is not a shape");
|
||||
// Align: the shape's id given as an equation is refused and nothing moves; the two equations
|
||||
// line up on their own.
|
||||
let second = ObjRef::new(ObjKind::Equation, id_of(&s.execute("insert.equation", json!({"latex": "y", "cell": "B30"})).unwrap(), "equation"));
|
||||
let before = (anchor_of(&s, shape), anchor_of(&s, equation));
|
||||
assert!(s.execute("arrange.align", json!({"kind": "equation", "ids": [shape.id, equation.id, second.id], "align": "left"})).is_err());
|
||||
assert_eq!((anchor_of(&s, shape), anchor_of(&s, equation)), before, "nothing is aligned with a refused id");
|
||||
s.execute("arrange.align", json!({"kind": "equation", "ids": [equation.id, second.id], "align": "left"})).unwrap();
|
||||
let (a, b) = (sheet(&s, 0).object_anchor(equation).unwrap(), sheet(&s, 0).object_anchor(second).unwrap());
|
||||
assert_eq!((a.cell.col, a.dx), (b.cell.col, b.dx), "the equations line up");
|
||||
assert!(s.execute("arrange.align", json!({"kind": "widget", "ids": [shape.id, equation.id], "align": "left"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrange_refuses_a_kind_that_is_present_but_not_a_name() {
|
||||
// `kind` is `shape` only when the parameter is absent: `null`, a number or another non-name
|
||||
// is refused, so a shape's id is not reordered or aligned under it. The `object.*` commands
|
||||
// refuse the same values, their kind being required.
|
||||
let mut s = s();
|
||||
let objs = every_kind(&mut s);
|
||||
let (shape, equation) = (objs[2], objs[4]);
|
||||
let order = |s: &Session| -> Vec<u32> { sheet(s, 0).shapes.iter().map(|c| c.id).collect() };
|
||||
let before = (order(&s), anchor_of(&s, shape));
|
||||
for kind in [json!(null), json!(7), json!(["shape"]), json!({"kind": "shape"}), json!(true)] {
|
||||
assert!(s.execute("arrange.bringForward", json!({"kind": kind, "id": shape.id, "toFront": true})).is_err(), "{kind}");
|
||||
assert!(s.execute("arrange.align", json!({"kind": kind, "ids": [shape.id, equation.id], "align": "left"})).is_err(), "{kind}");
|
||||
assert!(s.execute("object.move", json!({"kind": kind, "id": shape.id, "at": "A1"})).is_err(), "{kind}");
|
||||
}
|
||||
assert_eq!((order(&s), anchor_of(&s, shape)), before, "nothing moved");
|
||||
// Absent, the kind is `shape`, as before.
|
||||
s.execute("arrange.bringForward", json!({"id": shape.id, "toFront": true})).unwrap();
|
||||
assert_eq!(order(&s).last(), Some(&shape.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_refuses_an_id_that_is_not_an_object_of_the_kind() {
|
||||
// `arrange.align` and `arrange.group` refuse an id in `ids` that is not an object of the
|
||||
// kind, or not a number, as `arrange` and the `object.*` commands refuse it, instead of
|
||||
// aligning the rest and reporting success; nothing moves. With fewer than two ids they
|
||||
// change nothing and succeed.
|
||||
let mut s = s();
|
||||
let objs = every_kind(&mut s);
|
||||
let (shape, equation) = (objs[2], objs[4]);
|
||||
let second = ObjRef::new(ObjKind::Equation, id_of(&s.execute("insert.equation", json!({"latex": "y", "cell": "B30"})).unwrap(), "equation"));
|
||||
let before = (anchor_of(&s, shape), anchor_of(&s, equation), anchor_of(&s, second));
|
||||
for ids in [
|
||||
json!([shape.id, equation.id, second.id]),
|
||||
json!([equation.id, second.id, 9999]),
|
||||
json!([equation.id, second.id, -1]),
|
||||
json!([equation.id, second.id, "x"]),
|
||||
json!([equation.id, second.id, 1e12]),
|
||||
json!([equation.id, second.id, null]),
|
||||
] {
|
||||
assert!(s.execute("arrange.align", json!({"kind": "equation", "ids": ids, "align": "left"})).is_err(), "{ids}");
|
||||
assert!(s.execute("arrange.group", json!({"kind": "equation", "ids": ids})).is_err(), "{ids}");
|
||||
}
|
||||
assert_eq!((anchor_of(&s, shape), anchor_of(&s, equation), anchor_of(&s, second)), before, "nothing moved");
|
||||
s.execute("arrange.align", json!({"kind": "equation", "ids": [equation.id], "align": "left"})).unwrap();
|
||||
s.execute("arrange.align", json!({"kind": "equation", "align": "left"})).unwrap();
|
||||
assert_eq!(anchor_of(&s, equation), before.1, "fewer than two ids change nothing");
|
||||
s.execute("arrange.align", json!({"kind": "equation", "ids": [equation.id, second.id], "align": "left"})).unwrap();
|
||||
let (a, b) = (sheet(&s, 0).object_anchor(equation).unwrap(), sheet(&s, 0).object_anchor(second).unwrap());
|
||||
assert_eq!((a.cell.col, a.dx), (b.cell.col, b.dx), "the equations line up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_refuses_ids_that_are_not_an_array() {
|
||||
// `ids` may be absent (nothing to align) but, present, must be an array of object ids: `null`,
|
||||
// a number, a string or an object is refused by `arrange.align` and `arrange.group`, and
|
||||
// nothing moves.
|
||||
let mut s = s();
|
||||
let objs = every_kind(&mut s);
|
||||
let (shape, equation) = (objs[2], objs[4]);
|
||||
let before = (anchor_of(&s, shape), anchor_of(&s, equation));
|
||||
for ids in [json!(null), json!(7), json!("x"), json!({"ids": [1]})] {
|
||||
assert!(s.execute("arrange.align", json!({"kind": "shape", "ids": ids, "align": "left"})).is_err(), "{ids}");
|
||||
assert!(s.execute("arrange.group", json!({"kind": "shape", "ids": ids})).is_err(), "{ids}");
|
||||
}
|
||||
s.execute("arrange.align", json!({"kind": "shape", "align": "left"})).unwrap();
|
||||
s.execute("arrange.group", json!({"kind": "shape"})).unwrap();
|
||||
assert_eq!((anchor_of(&s, shape), anchor_of(&s, equation)), before, "nothing moved");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user