Files
bflatasticandClaude Opus 5.5 d527349f5a M4.20: Document Setup with bleed, transparency grid, paper and type options
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]>
2026-10-05 08:28:16 +07:00

267 lines
12 KiB
Rust
Raw Permalink 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));
}
#[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);
}