Files
designcraft/crates/engine/src/cmd/mathexpr.rs
T
Kushagra Kumar f9cfd99adc Text: adjust hyperlink and placeholder source ranges on story edits (#1219) (#1229)
* Text: adjust hyperlink and placeholder source ranges on story edits (#1219)

Text-anchored hyperlinks and data-merge placeholders store byte ranges
(`start..end`) pointing into story text. When text earlier in the story is
inserted or deleted, these offsets remained unchanged, causing hyperlink
targets and data-merge placeholders to drift across unrelated text or
point to invalid UTF-8 boundaries.

This change:
- Implements `Document::adjust_text_ranges` in `designcraft-doc` to shift
  or resize text hyperlinks and placeholders when story text is edited.
  Pure insertions at or before the link shift it forward; insertions
  inside expand the link's span; deletions before shift it back; deletions
  consuming or overlapping the link clamp to surviving text or prune it if
  entirely consumed.
- Cleans up obsolete hyperlinks and placeholders in `set_story_text`.
- Wires range adjustment into engine text commands: typing, deleting,
  selection replacement, autocorrect, `story.replaceRange`, find/change,
  and spelling change.
- Adds comprehensive unit tests in `designcraft-doc` and 7 integration
  regression tests in `crates/engine/tests/hyperlink_offsets.rs`.

* fix(engine): adjust hyperlink and placeholder ranges for physically removed text under track changes

* fix(engine): adjust text ranges for formatted paste, case change, and drag move

* fix(engine): record change case adjustments per character to preserve enclosed hyperlinks

* fix(engine): adjust text ranges for track changes, inline markers, and preserve zero-length ranges

- Route Track Changes accept/reject deletions through adjust_text_ranges, and transfer replaced text hyperlinks to inserted text so links survive accept.
- Hook inline marker operations to adjust_text_ranges (notes, endnotes, xref, anchored objects, index markers, tables, editorial notes, unvar, and place text).
- Replace datamerge::shift_after with adjust_text_ranges.
- Only prune ranges that the edit emptied (s < e && s_new >= e_new), preserving zero-length ranges.
- Drop inverted ranges first and use saturating arithmetic in remap_range.
- Preserve doc.hyperlinks and placeholders ordering in move_text_ranges by mutating in place.
- Remove dead wrapper mark_deleted and call mark_deleted_reporting.
- Fix changes.track invocation parameter to 'on'.
- Add comprehensive regression tests in doc and engine covering all review items.

* fix(text): guard cell find/change, pre-record tracked replaces, shift para xref anchors

* fix(text): record stable IDs for tracked replacement redirects

* fix(text): reconcile range adjustments for review feedback

* fix(text): adjust ranges on delete surrounding and clean up dead auto edit

* Prune text ranges on TOC index and live caption regeneration

* test(engine): format regenerating_index_prunes_story_hyperlinks line wrap
2026-11-07 19:54:00 -06:00

203 lines
9.4 KiB
Rust

//! Math expressions: LaTeX typeset by [`crate::math`] and placed as a graphic, inline at the
//! text insertion point (an anchored object) or as a frame. The LaTeX is kept as the object's
//! alt text, so it can be edited and exports accessibly.
use std::sync::Arc;
use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, Shape};
use designcraft_geom::{Affine, Rect, shapes};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{Result, Session};
fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: designcraft_doc::LayerId, at: (f64, f64)) -> Item {
let (svg, (w, h)) = crate::math::to_svg(latex, size);
let fresh = AssetId(d.alloc());
let aid = d.add_asset(Asset { id: fresh, name: "math.svg".into(), mime: "image/svg+xml".into(), data: Arc::new(svg), ..Default::default() });
let id = ItemId(d.alloc());
let r = Rect::new(at.0, at.1, at.0 + w, at.1 + h);
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r));
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (w, h),
xf: Affine::translate(at),
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
wrap: Default::default(),
clip: None,
fill: Default::default(),
});
it.alt_text = latex.to_string();
it.name = "Math".into();
it
}
/// The `size` parameter in points: 12 by default (also for non-finite values), clamped to 2..=400.
fn size_param(p: &Value) -> f64 {
p.get("size").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(12.0).clamp(2.0, 400.0)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"math.insert",
"Insert Math Expression…",
["Type"],
None,
"{latex, size?: points (12), x?, y?, spread?} or {id, latex, size?} — at the text insertion point (inline), else as a frame; with `id`: edits that equation",
has_doc,
insert
),
cmd!(query "math.svg", "Math Expression SVG", [], None, "{latex, size?} → {svg, width, height} — typeset without placing", has_doc, |_s, p| {
let latex = str_param(p, "latex").ok_or_else(|| bad("math.svg", "`latex` required"))?;
let (svg, (w, h)) = crate::math::to_svg(latex, size_param(p));
Ok(json!({"svg": String::from_utf8_lossy(&svg), "width": w, "height": h}))
}),
]
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let latex = str_param(p, "latex").ok_or_else(|| bad("math.insert", "`latex` required"))?.to_string();
let size = size_param(p);
let lid = s.doc()?.active_layer;
// Edit an existing expression in place.
if let Some(id) = super::id_param(p, "id") {
return s.edit(|d, _| {
let old = d.item(id).ok_or_else(|| bad("math.insert", "no such object"))?;
// The path and graphic live in the item's inner space, which `xf` maps to the spread:
// build the new ones at the old inner corner and keep `xf`.
let at = old.path.bounds().ok_or_else(|| bad("math.insert", "equation has no geometry"))?;
let it = math_item(d, &latex, size, lid, (at.x0, at.y0));
let target = d.item_mut(id).ok_or_else(|| bad("math.insert", "no such object"))?;
target.path = it.path;
target.content = it.content;
target.alt_text = latex.clone();
Ok(json!({"id": id.0}))
});
}
let text = s.doc()?.selection.text.filter(|t| t.cell.is_none());
let spread = super::spread_param(p, "math.insert")?;
let (px, py) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64));
s.edit(|d, sel| {
if let Some(t) = text {
let it = math_item(d, &latex, size, lid, (0.0, 0.0));
let r = t.range();
let del = r.start.min(d.story(t.story).map_or(0, |st| st.len()))..r.end.min(d.story(t.story).map_or(0, |st| st.len()));
if let Some(st) = d.story_mut(t.story) {
st.delete(del.clone());
}
d.adjust_text_ranges(t.story, del, 0);
let end = super::anchored::anchor_items(d, t.story, r.start, vec![it], &Default::default())?;
sel.text = Some(designcraft_doc::TextSel { anchor: end, focus: end, ..t });
return Ok(json!({"inline": true, "pos": end}));
}
let pr = d.spread(spread).and_then(|sp| sp.pages.first()).map(|pg| pg.margin_rect()).unwrap_or(Rect::new(36.0, 36.0, 300.0, 300.0));
let it = math_item(d, &latex, size, lid, (px.unwrap_or(pr.x0), py.unwrap_or(pr.y0)));
let id = it.id;
d.insert_item(spread, it, None)?;
sel.items = vec![id];
Ok(json!({"id": id.0}))
})
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
#[test]
fn math_svg_clamps_size_like_insert() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let dims = |s: &mut Session, size: f64| {
let r = s.execute("math.svg", &json!({"latex": "x", "size": size})).unwrap();
(r["width"].as_f64().unwrap(), r["height"].as_f64().unwrap())
};
let (w2, h2) = dims(&mut s, 2.0);
let (w400, h400) = dims(&mut s, 400.0);
for size in [0.0, -5.0, 1e-300] {
assert_eq!(dims(&mut s, size), (w2, h2), "size {size}");
}
for size in [1e9, 1e300] {
assert_eq!(dims(&mut s, size), (w400, h400), "size {size}");
}
}
#[test]
fn math_places_inline_and_as_frames() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("math.insert", &json!({"latex": r"\frac{a}{b} + \sqrt{x}", "x": 100, "y": 100})).unwrap();
let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
let d = s.doc().unwrap().doc.clone();
let it = d.item(id).unwrap();
assert_eq!(it.alt_text, r"\frac{a}{b} + \sqrt{x}");
assert!(it.bounds().height() > 20.0, "a fraction is tall");
// It draws: dark pixels inside the frame.
let mut rr = designcraft_render::Renderer::new();
rr.threads = 0;
let img = rr.render_page(&d, &s.cache, 0, 2.0, false, &Default::default()).unwrap();
let b = it.bounds();
let dark = (b.x0 as u32 * 2..b.x1 as u32 * 2)
.flat_map(|x| (b.y0 as u32 * 2..b.y1 as u32 * 2).map(move |y| (x, y)))
.filter(|&(x, y)| img.pixel(x, y)[0] < 100)
.count();
assert!(dark > 30, "{dark}");
// Edit in place.
s.execute("math.insert", &json!({"id": id.0, "latex": "x"})).unwrap();
assert!(s.doc().unwrap().doc.item(id).unwrap().bounds().height() < b.height());
// Inline at the insertion point.
let f = s.execute("frame.create", &json!({"rect": [72, 300, 400, 400], "content": "text", "text": "Area: "})).unwrap();
s.execute("text.select", &json!({"story": f["story"], "anchor": 6, "focus": 6})).unwrap();
assert_eq!(s.execute("math.insert", &json!({"latex": r"\pi r^2"})).unwrap()["inline"], true);
let st = &s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(f["story"].as_u64().unwrap())];
assert_eq!(st.objects.len(), 1);
}
/// The item's top-left corner in spread space (inner path corner through its transform).
fn origin(s: &Session, id: designcraft_doc::ItemId) -> designcraft_geom::Point {
let d = &s.doc().unwrap().doc;
let it = d.item(id).unwrap();
let r = it.path.bounds().unwrap();
it.xf * designcraft_geom::Point::new(r.x0, r.y0)
}
#[test]
fn editing_a_transformed_equation_keeps_its_place() {
for step in ["move", "rotate", "scale"] {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("math.insert", &json!({"latex": r"\frac{a}{b}", "x": 100, "y": 100})).unwrap();
let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
match step {
"move" => s.execute("transform.move", &json!({"ids": [id.0], "dx": 50, "dy": 40})),
"rotate" => s.execute("transform.rotate", &json!({"ids": [id.0], "angle": 30})),
_ => s.execute("transform.scale", &json!({"ids": [id.0], "sx": 2.0, "sy": 1.5})),
}
.unwrap();
let before = origin(&s, id);
s.execute("math.insert", &json!({"id": id.0, "latex": r"\frac{a}{b}"})).unwrap();
let after = origin(&s, id);
assert!((before.x - after.x).abs() < 1e-6 && (before.y - after.y).abs() < 1e-6, "{step}: {before:?} -> {after:?}");
}
}
#[test]
fn editing_a_moved_equation_keeps_its_bounds() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("math.insert", &json!({"latex": "x", "x": 100, "y": 100})).unwrap();
let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
s.execute("transform.move", &json!({"ids": [id.0], "dx": 50, "dy": 40})).unwrap();
let before = s.doc().unwrap().doc.item(id).unwrap().bounds();
s.execute("math.insert", &json!({"id": id.0, "latex": "x"})).unwrap();
let after = s.doc().unwrap().doc.item(id).unwrap().bounds();
assert_eq!((before.x0, before.y0), (after.x0, after.y0));
assert_eq!((after.x0, after.y0), (150.0, 140.0));
}
}