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:
prxnvt
2026-11-02 10:47:43 -05:00
95 changed files with 6317 additions and 218 deletions
+24 -12
View File
@@ -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",
+1
View File
@@ -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 {
+12 -9
View File
@@ -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")),
+305
View File
@@ -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, &[]);
}
}
}
}
}
}
+5 -5
View File
@@ -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
),
+61 -46
View File
@@ -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() {
+8 -2
View File
@@ -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)),
+7 -4
View File
@@ -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| {
+1
View File
@@ -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| {
+39
View File
@@ -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 {
+118
View File
@@ -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"));
}
+1
View File
@@ -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,