Files
vectorcraft/crates/engine/src/tests_docsetup.rs
T
dotCooCoo 81574b1648 Type: superscript, subscript and small caps proportions out of range in a file are read in range
document.setup accepts a superscript and subscript size of 1 to 500%
and a position of -500 to 500%, and a small caps size of 1 to 200%.
text.setFormat copies these proportions into the characters it
formats. A .vectorcraft file's characters, character styles and graph
label styles opened with the proportions they held. With a superscript
size of 0, the superscript characters were not drawn, and with a
superscript or small caps size of 1.797e308, moving the type failed
with "result would exceed the canvas".

These proportions are now read as the nearer end of each range when a
file opens: in the type of the layers, symbols, graph designs, patterns
and the pattern definition that Cancel puts back in pattern editing
mode, in graph label styles and in character styles. Proportions in
range and the other character attributes open as saved.

Docs: docs/mcp.md (Small Caps, Superscript and Subscript).
2026-10-18 01:21:24 +07:00

364 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! File → Document Setup (`document.setup`): validation, one undo step, round-trips, and the
//! options other commands read (typographer's quotes, superscript/subscript, SVG text export).
use serde_json::{Value, json};
use vectorcraft_doc::{CharPosition, DocSetup, ExportText, GridSize, NodeId, NodeKind, ScriptMetrics, TextObject, TextStyleDef, Unit};
use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
fn setup(s: &Session) -> &DocSetup {
&s.doc().unwrap().doc.setup
}
fn text(s: &Session, id: NodeId) -> TextObject {
match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("not text"),
}
}
fn create_text(s: &mut Session, t: &str) -> NodeId {
NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": t, "size": 20})).unwrap()["id"].as_u64().unwrap())
}
/// Click with the Type tool at (x, y) and type `keys` one at a time; returns the new text object.
fn type_at(s: &mut Session, x: f64, y: f64, keys: &[&str]) -> NodeId {
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
for k in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
}
let id = s.doc().unwrap().selection.objects[0];
for k in keys {
s.tool_text(k, v).unwrap();
}
s.tool_key(ToolKey::Escape, Default::default(), v).unwrap();
id
}
#[test]
fn query_reports_the_defaults_and_accepts_them_back() {
let mut s = session();
let q = s.execute("document.setup", &json!({})).unwrap();
assert_eq!(q["units"], "Points");
assert_eq!(q["bleed"], json!([0.0, 0.0, 0.0, 0.0]));
assert_eq!(q["gridColors"], json!(["#ffffff", "#cccccc"]));
assert_eq!(q["gridColorsName"], "Light");
assert_eq!(q["flattenerPreset"], "Medium Resolution");
assert_eq!(q["backgroundContents"], "transparent");
assert_eq!(q["quotes"], json!({"double": "“”", "single": "‘’"}));
assert_eq!(q["typographersQuotes"], true);
assert_eq!(q["superscript"], json!({"size": 58.3, "position": 33.3}));
// Feeding the report back changes nothing (and records no undo step).
assert_eq!(s.execute("document.setup", &q).unwrap(), q);
assert!(s.doc().unwrap().history.undo.is_empty());
assert_eq!(*setup(&s), DocSetup::default());
}
#[test]
fn many_settings_are_one_undo_step() {
let mut s = session();
let before = s.doc().unwrap().doc.clone();
let r = s
.execute(
"document.setup",
&json!({
"units": "Millimeters", "bleed": {"top": 9, "right": 4.5}, "gridSize": "large", "gridColors": "Blue",
"simulatePaper": true, "flattenerPreset": "high", "discardWhiteOverprint": false,
"outlineImages": true, "highlightSubstitutedGlyphs": true, "exportText": "appearance",
"superscript": {"size": 60}, "subscript": {"position": 20}, "smallCapsSize": 75,
}),
)
.unwrap();
assert_eq!(r["gridColorsName"], "Blue");
let d = &s.doc().unwrap().doc;
assert_eq!(d.units, Unit::Millimeters);
let st = &d.setup;
assert_eq!(st.bleed, [9.0, 0.0, 0.0, 4.5]);
assert_eq!((st.grid_size, st.simulate_paper, st.outline_images, st.highlight_substituted_glyphs), (GridSize::Large, true, true, true));
assert_eq!((st.flattener(), st.discard_white_overprint, st.export_text), ("High Resolution", false, ExportText::Appearance));
assert_eq!((st.superscript, st.subscript), (ScriptMetrics { size: 60.0, position: 33.3 }, ScriptMetrics { size: 58.3, position: 20.0 }));
assert_eq!(st.small_caps_size, 75.0);
let undo: Vec<&str> = s.doc().unwrap().history.undo.iter().map(|e| e.label.as_str()).collect();
assert_eq!(undo, ["Document Setup"]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(*s.doc().unwrap().doc, *before);
}
#[test]
fn bad_values_are_rejected_and_change_nothing() {
let mut s = session();
for p in [
json!({"bleed": 73}),
json!({"bleed": [1, 2, 3]}),
json!({"bleed": {"middle": 2}}),
json!({"bleed": -1}),
json!({"gridSize": "huge"}),
json!({"gridColors": ["#ffffff"]}),
json!({"gridColors": "Plaid"}),
json!({"language": "Klingon"}),
json!({"quotes": {"double": "“"}}),
json!({"quotes": {"triple": "“”"}}),
json!({"superscript": {"size": 0}}),
json!({"exportText": "pdf"}),
json!({"flattenerPreset": "Ultra"}),
json!({"backgroundContents": "grey"}),
json!({"simulatePaper": "yes"}),
json!({"units": "furlongs"}),
json!({"bleed": 9, "nonsense": true}),
json!([1, 2]),
] {
assert!(s.execute("document.setup", &p).is_err(), "{p}");
}
assert_eq!(*setup(&s), DocSetup::default());
assert!(s.doc().unwrap().history.undo.is_empty());
}
#[test]
fn set_units_is_an_alias() {
let mut s = session();
s.execute("document.setUnits", &json!({"units": "inches"})).unwrap();
assert_eq!(s.doc().unwrap().doc.units, Unit::Inches);
assert_eq!(s.execute("document.setup", &json!({})).unwrap()["units"], "Inches");
assert!(s.execute("document.setUnits", &json!({"units": "parsecs"})).is_err());
}
#[test]
fn language_picks_its_quotes_unless_quotes_are_given() {
let mut s = session();
s.execute("document.setup", &json!({"language": "german"})).unwrap();
assert_eq!(setup(&s).language, "German");
assert_eq!(setup(&s).quotes.double, ['„', '“']);
s.execute("document.setup", &json!({"language": "French", "quotes": {"double": ["“", "”"]}})).unwrap();
assert_eq!((setup(&s).quotes.double, setup(&s).quotes.single), (['“', '”'], ['‹', '›']));
}
#[test]
fn typographers_quotes_setting_changes_typed_quotes() {
let mut s = session();
let id = type_at(&mut s, 50.0, 50.0, &["\"", "H", "i", "\"", " ", "i", "t", "'", "s"]);
assert_eq!(text(&s, id).plain_text(), "“Hi” it’s");
// The document's quote style.
s.execute("document.setup", &json!({"language": "German"})).unwrap();
let id = type_at(&mut s, 50.0, 150.0, &["\"", "a", "\""]);
assert_eq!(text(&s, id).plain_text(), "„a“");
// Straight quotes with the option off.
s.execute("document.setup", &json!({"typographersQuotes": false})).unwrap();
let id = type_at(&mut s, 50.0, 250.0, &["\"", "b", "'"]);
assert_eq!(text(&s, id).plain_text(), "\"b'");
}
#[test]
fn smart_punctuation_uses_the_document_quotes() {
let mut s = session();
s.execute("document.setup", &json!({"language": "French"})).unwrap();
let id = create_text(&mut s, "\"oui\" 'non'");
s.execute("type.smartPunctuation", &json!({"scope": "document"})).unwrap();
assert_eq!(text(&s, id).plain_text(), "«oui» ‹non›");
}
#[test]
fn superscript_and_small_caps_use_and_follow_the_setup_sizes() {
let mut s = session();
let id = create_text(&mut s, "E=mc2");
s.execute("document.setup", &json!({"superscript": {"size": 50, "position": 40}})).unwrap();
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 4, "end": 5, "position": "superscript"})).unwrap();
s.execute("text.setFormat", &json!({"ids": [id.0], "smallCaps": true})).unwrap();
let t = text(&s, id);
assert_eq!(t.runs.last().unwrap().style.position, CharPosition::Superscript(ScriptMetrics { size: 50.0, position: 40.0 }));
assert!(t.runs.iter().all(|r| r.style.small_caps == Some(70.0)));
let narrow = t.cached_bounds.unwrap();
// A Document Setup change restyles the text in the same undo step.
let undo = s.doc().unwrap().history.undo.len();
s.execute("document.setup", &json!({"superscript": {"size": 100}, "smallCapsSize": 100})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
let t = text(&s, id);
assert_eq!(t.runs.last().unwrap().style.position, CharPosition::Superscript(ScriptMetrics { size: 100.0, position: 40.0 }));
assert!(t.runs.iter().all(|r| r.style.small_caps == Some(100.0)));
assert!(t.cached_bounds.unwrap().width() > narrow.width(), "bounds follow the bigger glyphs");
s.execute("text.setFormat", &json!({"ids": [id.0], "position": "normal", "smallCaps": false})).unwrap();
assert!(text(&s, id).runs.iter().all(|r| r.style.position == CharPosition::Normal && r.style.small_caps.is_none()));
assert!(s.execute("text.setFormat", &json!({"ids": [id.0], "position": "sideways"})).is_err());
}
#[test]
fn character_styles_follow_the_setup_sizes() {
let mut s = session();
let attrs = json!({"position": {"kind": "subscript", "size": 58.3, "position": 33.3}, "small_caps": 70.0});
Arc::make_mut(&mut s.doc_mut().unwrap().doc).char_styles.push(TextStyleDef { name: "Note".into(), attrs: attrs.as_object().unwrap().clone() });
s.execute("document.setup", &json!({"subscript": {"size": 40}, "smallCapsSize": 80})).unwrap();
let def = &s.doc().unwrap().doc.char_styles[0];
assert_eq!(def.attrs["position"], json!({"kind": "subscript", "size": 40.0, "position": 33.3}));
assert_eq!(def.attrs["small_caps"], json!(80.0));
}
/// A file whose type, symbols, graph label styles, character styles or pattern definition saved in
/// pattern editing mode hold superscript, subscript or small caps proportions outside the ranges
/// `document.setup` takes opens with each at the nearer end of its range; the other character
/// attributes open as saved.
#[test]
fn a_file_with_script_proportions_out_of_range_opens_with_them_in_range() {
let mut s = session();
let id = create_text(&mut s, "E=mc2");
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 4, "end": 5, "position": "superscript"})).unwrap();
s.execute("text.setFormat", &json!({"ids": [id.0], "smallCaps": true})).unwrap();
let water = create_text(&mut s, "H2O");
s.execute("text.setRangeStyle", &json!({"id": water.0, "start": 1, "end": 2, "position": "subscript"})).unwrap();
let csv = ",2024\nQ1,3\nQ2,5";
let graph = s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 150, "width": 200, "height": 100, "csv": csv})).unwrap();
let graph = NodeId(graph["id"].as_u64().unwrap());
let exponent = create_text(&mut s, "x2");
s.execute("text.setRangeStyle", &json!({"id": exponent.0, "start": 1, "end": 2, "position": "superscript"})).unwrap();
s.execute("select.set", &json!({"ids": [exponent.0]})).unwrap();
s.execute("object.pattern.make", &json!({"name": "Exponent", "width": 20, "height": 20})).unwrap();
s.execute("object.pattern.done", &json!({})).unwrap();
// Saved in pattern editing mode: Cancel puts back the definition from before the edit.
s.execute("object.pattern.edit", &json!({"name": "Exponent"})).unwrap();
let mut d = (*s.doc().unwrap().doc).clone();
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.runs[0].style.small_caps = Some(-5.0);
let two = &mut t.runs[1].style;
two.position = CharPosition::Superscript(ScriptMetrics { size: 0.0, position: f64::MAX });
two.small_caps = Some(f64::MAX);
}
if let Some(NodeKind::Text(t)) = d.node_mut(water).map(|n| &mut n.kind) {
t.runs[1].style.position = CharPosition::Subscript(ScriptMetrics { size: f64::MAX, position: -1000.0 });
t.runs[1].style.tracking = -5000.0;
}
let mut art = d.node(id).unwrap().clone();
art.id = NodeId(9000);
d.symbols.push(vectorcraft_doc::Symbol { name: "Formula".into(), art: Arc::new(art) });
if let Some(g) = d.node_mut(graph).and_then(|n| n.extras_if_any()).and_then(|e| e.graph.as_deref_mut()) {
let position = CharPosition::Subscript(ScriptMetrics { size: 0.0, position: 1000.0 });
g.label_styles.legend = Some(vectorcraft_doc::CharStyle { position, small_caps: Some(0.0), ..vectorcraft_doc::CharStyle::default() });
}
let original = d.pattern_edit.as_mut().and_then(|pe| pe.original.as_mut()).and_then(|p| p.art.first_mut());
if let Some(NodeKind::Text(t)) = original.map(|a| &mut Arc::make_mut(a).kind) {
t.runs[1].style.position = CharPosition::Superscript(ScriptMetrics { size: 0.0, position: f64::MAX });
}
let attrs = json!({"position": {"kind": "superscript", "size": 1000.0, "position": -1000.0}, "small_caps": 0.0, "tracking": -5000.0});
d.char_styles.push(TextStyleDef { name: "Note".into(), attrs: attrs.as_object().unwrap().clone() });
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&d));
s.execute("document.open", &json!({"name": "scripts.vectorcraft", "dataBase64": data})).unwrap();
let raised = CharPosition::Superscript(ScriptMetrics { size: 1.0, position: 500.0 });
let t = text(&s, id);
assert_eq!((t.runs[0].style.small_caps, t.runs[1].style.small_caps), (Some(1.0), Some(200.0)));
assert_eq!(t.runs[1].style.position, raised);
let t = text(&s, water);
assert_eq!(t.runs[1].style.position, CharPosition::Subscript(ScriptMetrics { size: 500.0, position: -500.0 }));
assert_eq!(t.runs[1].style.tracking, -5000.0, "other attributes open as saved");
let doc = &s.doc().unwrap().doc;
match &doc.symbols.iter().find(|s| s.name == "Formula").unwrap().art.kind {
NodeKind::Text(t) => assert_eq!(t.runs[1].style.position, raised),
_ => panic!("the symbol's art is not text"),
}
let legend = doc.node(graph).unwrap().extras().graph.as_ref().unwrap().label_styles.legend.clone().unwrap();
assert_eq!((legend.position, legend.small_caps), (CharPosition::Subscript(ScriptMetrics { size: 1.0, position: 500.0 }), Some(1.0)));
let def = doc.char_styles.iter().find(|d| d.name == "Note").unwrap();
assert_eq!(def.attrs["position"], json!({"kind": "superscript", "size": 500.0, "position": -500.0}));
assert_eq!((&def.attrs["small_caps"], &def.attrs["tracking"]), (&json!(1.0), &json!(-5000.0)));
s.execute("object.pattern.cancel", &json!({})).unwrap();
match &s.doc().unwrap().doc.pattern("Exponent").unwrap().art[0].kind {
NodeKind::Text(t) => assert_eq!(t.runs[1].style.position, raised),
_ => panic!("the pattern's art is not text"),
}
// With the file's proportions kept as saved, the move failed with "result would exceed the canvas".
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
}
#[test]
fn setup_survives_save_and_load() {
let mut s = session();
s.execute(
"document.setup",
&json!({"bleed": [9, 9, 18, 18], "gridColors": ["#ff0000", "#00ff00"], "language": "Japanese", "exportText": "appearance"}),
)
.unwrap();
let d = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save_file(d)).unwrap();
assert_eq!(back.setup, d.setup);
assert_eq!(back.setup.grid_colors_name(), "Custom");
}
/// A file whose bleed, superscript, subscript or small caps size lies outside the range
/// `document.setup` takes opens with it at the nearer end of that range, and the setup it reports
/// can be fed back.
#[test]
fn a_file_with_setup_values_out_of_range_opens_with_them_in_range() {
let mut s = session();
let mut d = (*s.doc().unwrap().doc).clone();
d.setup.bleed = [-5.0, 100.0, f64::MAX, 9.0];
d.setup.superscript = ScriptMetrics { size: 0.0, position: f64::MAX };
d.setup.subscript = ScriptMetrics { size: f64::MAX, position: -1000.0 };
d.setup.small_caps_size = -5.0;
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&d));
s.execute("document.open", &json!({"name": "setup.vectorcraft", "dataBase64": data})).unwrap();
let st = setup(&s);
assert_eq!(st.bleed, [0.0, 72.0, 72.0, 9.0]);
assert_eq!(st.superscript, ScriptMetrics { size: 1.0, position: 500.0 });
assert_eq!(st.subscript, ScriptMetrics { size: 500.0, position: -500.0 });
assert_eq!(st.small_caps_size, 1.0);
let q = s.execute("document.setup", &json!({})).unwrap();
assert_eq!(s.execute("document.setup", &q).unwrap(), q);
}
#[test]
fn svg_text_export_follows_the_export_text_setting() {
let mut s = session();
create_text(&mut s, "Hello");
let svg = |s: &mut Session, p: Value| {
let mut p = p;
p["format"] = json!("svg");
let r = s.execute("document.export", &p).unwrap();
String::from_utf8(vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()).unwrap()
};
assert!(svg(&mut s, json!({})).contains("<text"));
s.execute("document.setup", &json!({"exportText": "appearance"})).unwrap();
assert!(!svg(&mut s, json!({})).contains("<text"));
// An explicit option still wins.
assert!(svg(&mut s, json!({"outlineText": false})).contains("<text"));
}
#[test]
fn discard_white_overprint_keeps_white_overprints_in_overprint_preview() {
let mut s = session();
let rect = |s: &mut Session, color: &str| {
let id = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"ids": [id], "color": color})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [id], "none": true})).unwrap();
id
};
rect(&mut s, "#ff0000");
let white = rect(&mut s, "#ffffff");
s.execute("object.setOverprint", &json!({"fill": true, "ids": [white]})).unwrap();
let centre = |s: &Session| {
let opts = vectorcraft_render::RenderOptions { background: Some([255; 4]), overprint_preview: true, ..Default::default() };
vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 40, 40, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(20, 20)
};
// On by default: the white knocks out as it will print.
assert_eq!(centre(&s), [255, 255, 255, 255]);
s.execute("document.setup", &json!({"discardWhiteOverprint": false})).unwrap();
let [r, g, b, _] = centre(&s);
assert!(r > 200 && g < 60 && b < 60, "a white overprint vanishes: {:?}", [r, g, b]);
}
#[test]
fn saved_flattener_presets_and_background_contents() {
let mut s = session();
s.execute("flattener.presets.save", &json!({"name": "Proofs", "preset": "high"})).unwrap();
assert_eq!(s.execute("document.setup", &json!({"flattenerPreset": "proofs"})).unwrap()["flattenerPreset"], "Proofs");
assert_eq!(s.execute("document.setup", &json!({"flattenerPreset": "Medium Resolution"})).unwrap()["flattenerPreset"], "Medium Resolution");
assert_eq!(setup(&s).flattener_preset, None, "the default is not stored");
let r = s.execute("document.setup", &json!({"backgroundContents": "white"})).unwrap();
assert_eq!(r["backgroundContents"], "white");
assert_eq!(setup(&s).background, vectorcraft_doc::Background::White);
}