mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
File > Document Setup is a tabbed dialog over one engine command, document.setup (no params: a query; any keys: one undo step; document.setUnits stays as the units alias). - General: units, bleed (four sides with a link toggle), Edit Artboards, Show Images in Outline Mode (greyscale pixels under the frame), Highlight Substituted Fonts/Glyphs (pink boxes on screen only). - Transparency: grid size and colour presets or custom colours, Simulate Colored Paper, the flattener preset (built-in or saved) and Discard White Overprint (Overprint Preview keeps white overprints visible). - Type: language with its quotes, Use Typographer's Quotes for the Type tool and Smart Punctuation, superscript/subscript size and position and small caps size (Character panel position/smallCaps; changing the setup restyles the text in the same undo step), text export (SVG text as text or outlines by default). - The canvas draws a red bleed outline and the grid in the document's colours and cell size as one textured quad, also in rotated views; a white Background Contents exports raster formats opaque. Model: Document.setup (DocSetup, defaulted, skipped when default) and CharStyle position/small_caps; old files load unchanged. tests_transparencygrid's helper now finds the grid by its texture (the grid is one textured mesh instead of per-cell rectangles). Co-Authored-By: Claude Opus 5.5 <[email protected]>
267 lines
12 KiB
Rust
267 lines
12 KiB
Rust
//! 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));
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
|
||
#[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);
|
||
}
|