mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-10 04:19:48 -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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user