mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 06:07:06 -06:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
25df58e722
commit
d527349f5a
@@ -0,0 +1,295 @@
|
||||
//! File → Document Setup (`document.setup`): units, bleed, the transparency grid and simulated
|
||||
//! paper, output options and the document's type options.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::setup::{DEFAULT_FLATTENER, GRID_COLOR_PRESETS, LANGUAGES, MAX_BLEED};
|
||||
use vectorcraft_doc::{Background, CharPosition, CharStyle, DocSetup, Document, ExportText, GridSize, NodeKind, Quotes, ScriptMetrics};
|
||||
|
||||
use super::flatten::{FlattenOptions, FlattenerPreset};
|
||||
|
||||
use super::*;
|
||||
|
||||
const C: &str = "document.setup";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"document.setup",
|
||||
"Document Setup",
|
||||
[],
|
||||
None,
|
||||
"{units?: \"Points\"|\"Inches\"|\"Millimeters\"|…, bleed?: pt (all sides)|[top, bottom, left, right]|{top?, bottom?, left?, right?} (0–72 pt), outlineImages?: bool (show images in Outline mode), highlightSubstitutedFonts?: bool, highlightSubstitutedGlyphs?: bool, gridSize?: \"small\"|\"medium\"|\"large\", gridColors?: \"Light\"|\"Medium\"|\"Dark\"|\"Red\"|\"Orange\"|\"Green\"|\"Blue\"|\"Purple\"|[colour, colour] (transparency grid; the first is also the paper colour), simulatePaper?: bool, flattenerPreset?: \"High Resolution\"|\"Medium Resolution\"|\"Low Resolution\"|a saved preset (flattener.presets.list), discardWhiteOverprint?: bool, language?: \"English: USA\"|\"German\"|\"French\"|… (also picks its quotes), quotes?: {double?: \"“”\", single?: \"‘’\"} (beside a language: only if they differ from the current ones), typographersQuotes?: bool (typed straight quotes become the document's quotes), superscript?: {size?: %, position?: %}, subscript?: {size?: %, position?: %}, smallCapsSize?: %, exportText?: \"editable\"|\"appearance\" (SVG text as text or as outlines), backgroundContents?: \"transparent\"|\"white\" (white: raster exports are white behind the art)} change the document setup in one undo step; no params → the current setup",
|
||||
has_doc,
|
||||
setup
|
||||
)]
|
||||
}
|
||||
|
||||
/// The setup as `document.setup` reports it (and accepts it back).
|
||||
pub(crate) fn setup_json(d: &Document) -> Value {
|
||||
let s = &d.setup;
|
||||
let pair = |p: [char; 2]| p.iter().collect::<String>();
|
||||
json!({
|
||||
"units": d.units.label(),
|
||||
"bleed": s.bleed,
|
||||
"outlineImages": s.outline_images,
|
||||
"highlightSubstitutedFonts": s.highlight_substituted_fonts,
|
||||
"highlightSubstitutedGlyphs": s.highlight_substituted_glyphs,
|
||||
"gridSize": s.grid_size.id(),
|
||||
"gridColors": s.grid_colors.map(|c| c.to_hex()),
|
||||
"gridColorsName": s.grid_colors_name(),
|
||||
"simulatePaper": s.simulate_paper,
|
||||
"flattenerPreset": s.flattener(),
|
||||
"discardWhiteOverprint": s.discard_white_overprint,
|
||||
"language": s.language,
|
||||
"quotes": {"double": pair(s.quotes.double), "single": pair(s.quotes.single)},
|
||||
"typographersQuotes": s.typographers_quotes,
|
||||
"superscript": s.superscript,
|
||||
"subscript": s.subscript,
|
||||
"smallCapsSize": s.small_caps_size,
|
||||
"exportText": s.export_text.id(),
|
||||
"backgroundContents": s.background.id(),
|
||||
})
|
||||
}
|
||||
|
||||
fn setup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let empty = Map::new();
|
||||
let o = match p {
|
||||
Value::Null => &empty,
|
||||
Value::Object(o) => o,
|
||||
_ => return Err(bad(C, "params must be an object")),
|
||||
};
|
||||
let st = s.doc()?;
|
||||
if o.is_empty() {
|
||||
return Ok(setup_json(&st.doc));
|
||||
}
|
||||
let mut new = st.doc.setup.clone();
|
||||
let mut units = st.doc.units;
|
||||
// The language goes first: it picks its quotes, which `quotes` may then override.
|
||||
let current = st.doc.setup.quotes;
|
||||
for (k, v) in o.iter().filter(|(k, _)| *k == "language").chain(o.iter().filter(|(k, _)| *k != "language")) {
|
||||
// With a language, unchanged quotes (a report fed back) don't undo the language's quotes.
|
||||
if k == "quotes" && o.contains_key("language") && quotes_param(v, current)? == current {
|
||||
continue;
|
||||
}
|
||||
apply(&mut new, &mut units, k, v, &s.prefs.flattener_presets)?;
|
||||
}
|
||||
let st = s.doc()?;
|
||||
if new == st.doc.setup && units == st.doc.units {
|
||||
return Ok(setup_json(&st.doc));
|
||||
}
|
||||
s.edit("Document Setup", |d, _| {
|
||||
let scripts_changed =
|
||||
(new.superscript, new.subscript, new.small_caps_size) != (d.setup.superscript, d.setup.subscript, d.setup.small_caps_size);
|
||||
d.setup = new;
|
||||
d.units = units;
|
||||
if scripts_changed {
|
||||
restamp_scripts(d);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(setup_json(&s.doc()?.doc))
|
||||
}
|
||||
|
||||
fn flag(k: &str, v: &Value) -> Result<bool> {
|
||||
v.as_bool().ok_or_else(|| bad(C, format!("{k} must be true or false")))
|
||||
}
|
||||
|
||||
fn percent(k: &str, v: &Value, min: f64, max: f64) -> Result<f64> {
|
||||
v.as_f64().filter(|x| (min..=max).contains(x)).ok_or_else(|| bad(C, format!("{k} must be a number from {min} to {max}")))
|
||||
}
|
||||
|
||||
fn apply(s: &mut DocSetup, units: &mut vectorcraft_doc::Unit, k: &str, v: &Value, saved: &[FlattenerPreset]) -> Result<()> {
|
||||
match k {
|
||||
"units" => *units = v.as_str().and_then(vectorcraft_doc::Unit::named).ok_or_else(|| bad(C, "unknown units"))?,
|
||||
"bleed" => s.bleed = bleed_param(v, s.bleed, C)?,
|
||||
"outlineImages" => s.outline_images = flag(k, v)?,
|
||||
"highlightSubstitutedFonts" => s.highlight_substituted_fonts = flag(k, v)?,
|
||||
"highlightSubstitutedGlyphs" => s.highlight_substituted_glyphs = flag(k, v)?,
|
||||
"gridSize" => s.grid_size = v.as_str().and_then(GridSize::parse).ok_or_else(|| bad(C, "gridSize must be small, medium or large"))?,
|
||||
"gridColors" => s.grid_colors = grid_colors(v)?,
|
||||
"simulatePaper" => s.simulate_paper = flag(k, v)?,
|
||||
"flattenerPreset" => s.flattener_preset = flattener_preset(v, saved)?,
|
||||
"discardWhiteOverprint" => s.discard_white_overprint = flag(k, v)?,
|
||||
"language" => {
|
||||
let l = v.as_str().unwrap_or("");
|
||||
let (name, quotes) = LANGUAGES.iter().find(|(n, _)| n.eq_ignore_ascii_case(l)).ok_or_else(|| {
|
||||
bad(C, format!("unknown language `{l}` (one of {})", LANGUAGES.iter().map(|(n, _)| *n).collect::<Vec<_>>().join(", ")))
|
||||
})?;
|
||||
s.language = name.to_string();
|
||||
s.quotes = *quotes;
|
||||
}
|
||||
"quotes" => s.quotes = quotes_param(v, s.quotes)?,
|
||||
"typographersQuotes" => s.typographers_quotes = flag(k, v)?,
|
||||
"superscript" => s.superscript = script_param(k, v, s.superscript)?,
|
||||
"subscript" => s.subscript = script_param(k, v, s.subscript)?,
|
||||
"smallCapsSize" => s.small_caps_size = percent(k, v, 1.0, 200.0)?,
|
||||
"exportText" => s.export_text = v.as_str().and_then(ExportText::parse).ok_or_else(|| bad(C, "exportText must be editable or appearance"))?,
|
||||
"backgroundContents" => s.background = background_param(v, C)?,
|
||||
// Read-only values `document.setup` reports.
|
||||
"gridColorsName" => {}
|
||||
_ => return Err(bad(C, format!("unknown setting `{k}`"))),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A built-in flattener preset (by name or id) or a saved one, stored by its display name (None:
|
||||
/// the default preset).
|
||||
fn flattener_preset(v: &Value, saved: &[FlattenerPreset]) -> Result<Option<String>> {
|
||||
let name = v.as_str().unwrap_or("").trim();
|
||||
let builtin = FlattenOptions::PRESETS
|
||||
.into_iter()
|
||||
.filter_map(|id| Some((id, FlattenOptions::preset_label(id)?)))
|
||||
.find(|(id, label)| id.eq_ignore_ascii_case(name) || label.eq_ignore_ascii_case(name))
|
||||
.map(|(_, label)| label);
|
||||
let found = builtin.or_else(|| saved.iter().map(|p| p.name.as_str()).find(|n| n.eq_ignore_ascii_case(name))).ok_or_else(|| {
|
||||
bad(
|
||||
C,
|
||||
format!(
|
||||
"unknown flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution or a saved one: see flattener.presets.list)"
|
||||
),
|
||||
)
|
||||
})?;
|
||||
Ok((found != DEFAULT_FLATTENER).then(|| found.to_string()))
|
||||
}
|
||||
|
||||
/// `"transparent"` or `"white"`.
|
||||
pub(crate) fn background_param(v: &Value, cmd: &str) -> Result<Background> {
|
||||
v.as_str().and_then(Background::parse).ok_or_else(|| bad(cmd, "backgroundContents must be transparent or white"))
|
||||
}
|
||||
|
||||
/// `pt` (every side), `[top, bottom, left, right]` or `{top?, bottom?, left?, right?}`.
|
||||
pub(crate) fn bleed_param(v: &Value, current: [f64; 4], cmd: &str) -> Result<[f64; 4]> {
|
||||
let side = |x: &Value| x.as_f64().filter(|b| (0.0..=MAX_BLEED).contains(b));
|
||||
let err = || bad(cmd, format!("bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}}"));
|
||||
match v {
|
||||
Value::Number(_) => side(v).map(|b| [b; 4]).ok_or_else(err),
|
||||
Value::Array(a) if a.len() == 4 => {
|
||||
let mut out = [0.0; 4];
|
||||
for (o, x) in out.iter_mut().zip(a) {
|
||||
*o = side(x).ok_or_else(err)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Value::Object(o) => {
|
||||
let mut out = current;
|
||||
for (k, x) in o {
|
||||
let i = ["top", "bottom", "left", "right"].iter().position(|s| s == k).ok_or_else(err)?;
|
||||
out[i] = side(x).ok_or_else(err)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
_ => Err(err()),
|
||||
}
|
||||
}
|
||||
|
||||
fn grid_colors(v: &Value) -> Result<[vectorcraft_color::Color; 2]> {
|
||||
if let Some(name) = v.as_str() {
|
||||
return GRID_COLOR_PRESETS.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, c)| *c).ok_or_else(|| {
|
||||
bad(C, format!("unknown grid colours `{name}` (one of {})", GRID_COLOR_PRESETS.iter().map(|(n, _)| *n).collect::<Vec<_>>().join(", ")))
|
||||
});
|
||||
}
|
||||
match v.as_array().map(|a| a.iter().map(color_value).collect::<Option<Vec<_>>>()) {
|
||||
Some(Some(c)) if c.len() == 2 => Ok([c[0], c[1]]),
|
||||
_ => Err(bad(C, "gridColors must be a preset name or two colours")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A quote pair: a two-character string or two one-character strings.
|
||||
fn quote_pair(v: &Value) -> Option<[char; 2]> {
|
||||
let chars: Vec<char> = match v {
|
||||
Value::String(s) => s.chars().collect(),
|
||||
Value::Array(a) => a.iter().map(|x| x.as_str().and_then(|s| s.chars().next().filter(|_| s.chars().count() == 1))).collect::<Option<_>>()?,
|
||||
_ => return None,
|
||||
};
|
||||
match chars[..] {
|
||||
[a, b] if !a.is_control() && !b.is_control() && !a.is_whitespace() && !b.is_whitespace() => Some([a, b]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn quotes_param(v: &Value, mut q: Quotes) -> Result<Quotes> {
|
||||
let o = v.as_object().ok_or_else(|| bad(C, "quotes must be {double?, single?}"))?;
|
||||
for (k, x) in o {
|
||||
let pair = quote_pair(x).ok_or_else(|| bad(C, format!("quotes.{k} must be an opening and a closing quote, e.g. \"“”\"")))?;
|
||||
match k.as_str() {
|
||||
"double" => q.double = pair,
|
||||
"single" => q.single = pair,
|
||||
_ => return Err(bad(C, format!("unknown quotes key `{k}` (double, single)"))),
|
||||
}
|
||||
}
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
fn script_param(k: &str, v: &Value, mut m: ScriptMetrics) -> Result<ScriptMetrics> {
|
||||
let o = v.as_object().ok_or_else(|| bad(C, format!("{k} must be {{size?, position?}}")))?;
|
||||
for (key, x) in o {
|
||||
match key.as_str() {
|
||||
"size" => m.size = percent(&format!("{k}.size"), x, 1.0, 500.0)?,
|
||||
"position" => m.position = percent(&format!("{k}.position"), x, -500.0, 500.0)?,
|
||||
_ => return Err(bad(C, format!("unknown {k} key `{key}` (size, position)"))),
|
||||
}
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// `position?: "normal"|"superscript"|"subscript"` and `smallCaps?: bool` from a character
|
||||
/// command's params, in the document's Document Setup proportions.
|
||||
pub(crate) fn script_params(p: &Value, setup: &DocSetup, cmd: &str) -> Result<(Option<CharPosition>, Option<Option<f64>>)> {
|
||||
let position = match p.get("position").filter(|v| !v.is_null()) {
|
||||
None => None,
|
||||
Some(v) => Some(match v.as_str().map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("normal") => CharPosition::Normal,
|
||||
Some("superscript") => CharPosition::Superscript(setup.superscript),
|
||||
Some("subscript") => CharPosition::Subscript(setup.subscript),
|
||||
_ => return Err(bad(cmd, "position must be normal, superscript or subscript")),
|
||||
}),
|
||||
};
|
||||
let small_caps = match p.get("smallCaps").filter(|v| !v.is_null()) {
|
||||
None => None,
|
||||
Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "smallCaps must be true or false"))?.then_some(setup.small_caps_size)),
|
||||
};
|
||||
Ok((position, small_caps))
|
||||
}
|
||||
|
||||
/// A superscript or subscript in `setup`'s proportions.
|
||||
fn position_in(setup: &DocSetup, p: CharPosition) -> CharPosition {
|
||||
match p {
|
||||
CharPosition::Normal => CharPosition::Normal,
|
||||
CharPosition::Superscript(_) => CharPosition::Superscript(setup.superscript),
|
||||
CharPosition::Subscript(_) => CharPosition::Subscript(setup.subscript),
|
||||
}
|
||||
}
|
||||
|
||||
/// After the superscript, subscript or small caps proportions changed: every run (and character
|
||||
/// style) using them takes the new ones.
|
||||
fn restamp_scripts(d: &mut Document) {
|
||||
let uses = |st: &CharStyle| st.position != CharPosition::Normal || st.small_caps.is_some();
|
||||
let mut ids = vec![];
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& t.runs.iter().any(|r| uses(&r.style))
|
||||
{
|
||||
ids.push(n.id);
|
||||
}
|
||||
});
|
||||
let setup = d.setup.clone();
|
||||
for id in ids {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
for st in t.runs.iter_mut().map(|r| &mut r.style).filter(|st| uses(st)) {
|
||||
st.position = position_in(&setup, st.position);
|
||||
st.small_caps = st.small_caps.map(|_| setup.small_caps_size);
|
||||
}
|
||||
super::typecmd::refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
// Character styles store the attributes they set by their serialized names.
|
||||
for def in &mut d.char_styles {
|
||||
if let Some(v) = def.attrs.get_mut("position")
|
||||
&& let Ok(p) = serde_json::from_value::<CharPosition>(v.clone())
|
||||
{
|
||||
*v = serde_json::to_value(position_in(&setup, p)).unwrap_or(Value::Null);
|
||||
}
|
||||
if let Some(v) = def.attrs.get_mut("small_caps").filter(|v| v.is_number()) {
|
||||
*v = json!(setup.small_caps_size);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,7 +176,9 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
let scale = o.scale.unwrap_or(1.0).clamp(0.01, 64.0);
|
||||
check_format_size(f, region.width() * scale, region.height() * scale)?;
|
||||
vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?;
|
||||
let img = vectorcraft_render::Renderer::new().render_region(doc, region, scale, f.id == "jpg");
|
||||
// JPEG has no alpha; New Document → Background Contents: White fills the artboard too.
|
||||
let white = f.id == "jpg" || doc.setup.background == vectorcraft_doc::Background::White;
|
||||
let img = vectorcraft_render::Renderer::new().render_region(doc, region, scale, white);
|
||||
match f.id {
|
||||
"png" => img.to_png(),
|
||||
"webp" => img.to_webp(),
|
||||
|
||||
@@ -31,7 +31,7 @@ pub const OPTIONS: &[FormatOption] = &[
|
||||
name: "outlineText",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "Fonts: text as glyph outlines (viewable without the fonts) instead of <text>",
|
||||
description: "Fonts: text as glyph outlines (viewable without the fonts) instead of <text>; default: Document Setup → Type → Export (Preserve Text Appearance: true)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "images",
|
||||
@@ -99,6 +99,10 @@ pub fn options_map(p: &Value) -> Result<Map<String, Value>, String> {
|
||||
/// The writer options and the artboards (`None`: the art bounds) an SVG export of `doc` covers.
|
||||
fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec<Option<usize>>), String> {
|
||||
let mut m = options_map(p)?;
|
||||
// Fonts: unless chosen, Document Setup → Type → Export decides (appearance = outlines).
|
||||
if !m.contains_key("outlineText") && doc.setup.export_text == vectorcraft_doc::ExportText::Appearance {
|
||||
m.insert("outlineText".into(), Value::Bool(true));
|
||||
}
|
||||
let picks: Map<String, Value> = ARTBOARD_PARAMS.iter().chain(["useArtboards"].iter()).filter_map(|k| m.remove_entry(*k)).collect();
|
||||
let boards = Boards::deserialize(Value::Object(picks)).map_err(|e| format!("SVG options: {e}"))?;
|
||||
let opts = ExportOptions::deserialize(Value::Object(m)).map_err(|e| format!("SVG options: {e}"))?;
|
||||
|
||||
@@ -12,6 +12,7 @@ mod create;
|
||||
pub(crate) mod distortcmds;
|
||||
mod docinfo;
|
||||
mod docmenu;
|
||||
pub(crate) mod docsetup;
|
||||
mod draw2;
|
||||
mod edit;
|
||||
mod effectcmd;
|
||||
@@ -211,6 +212,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(newart::specs());
|
||||
v.extend(colortheme::specs());
|
||||
v.extend(fileio::pdf::specs());
|
||||
v.extend(docsetup::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character / Paragraph",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool}",
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool}",
|
||||
has_doc,
|
||||
set_format
|
||||
),
|
||||
@@ -93,6 +93,8 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"underline",
|
||||
"strikethrough",
|
||||
"allCaps",
|
||||
"smallCaps",
|
||||
"position",
|
||||
"leftIndent",
|
||||
"rightIndent",
|
||||
"firstLineIndent",
|
||||
@@ -103,6 +105,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !keys.iter().any(|k| p.get(*k).is_some()) {
|
||||
return Err(bad(C, "nothing to change"));
|
||||
}
|
||||
let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?;
|
||||
s.edit("Character", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
@@ -132,6 +135,12 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = flag("allCaps") {
|
||||
st.all_caps = v;
|
||||
}
|
||||
if let Some(v) = position {
|
||||
st.position = v;
|
||||
}
|
||||
if let Some(v) = small_caps {
|
||||
st.small_caps = v;
|
||||
}
|
||||
}
|
||||
let para = &mut t.para;
|
||||
if let Some(v) = num("leftIndent") {
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, features?: [\"dlig\", \"-liga\", …]} style a character range (runs are split at the range ends) → {id, runs}",
|
||||
"{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …]} style a character range (runs are split at the range ends) → {id, runs}",
|
||||
has_doc,
|
||||
set_range_style
|
||||
),
|
||||
@@ -140,6 +140,8 @@ pub(crate) struct CharChange {
|
||||
strikethrough: Option<bool>,
|
||||
all_caps: Option<bool>,
|
||||
features: Option<Vec<String>>,
|
||||
position: Option<vectorcraft_doc::CharPosition>,
|
||||
small_caps: Option<Option<f64>>,
|
||||
}
|
||||
|
||||
/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults).
|
||||
@@ -169,8 +171,10 @@ fn auto_or_num(p: &Value, k: &str, cmd: &str) -> Result<Option<Option<f64>>> {
|
||||
}
|
||||
|
||||
impl CharChange {
|
||||
pub(crate) fn parse(p: &Value, cmd: &str) -> Result<Self> {
|
||||
/// `setup` gives the superscript, subscript and small caps proportions.
|
||||
pub(crate) fn parse(p: &Value, cmd: &str, setup: &vectorcraft_doc::DocSetup) -> Result<Self> {
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64);
|
||||
let (position, small_caps) = super::docsetup::script_params(p, setup, cmd)?;
|
||||
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
|
||||
let mut stroke_opts = match p.get("strokeOptions") {
|
||||
None | Some(Value::Null) => StrokeChange::default(),
|
||||
@@ -198,6 +202,8 @@ impl CharChange {
|
||||
strikethrough: flag("strikethrough"),
|
||||
all_caps: flag("allCaps"),
|
||||
features: features_param(p, cmd)?,
|
||||
position,
|
||||
small_caps,
|
||||
};
|
||||
if c.size.is_some_and(|v| v <= 0.0) {
|
||||
return Err(bad(cmd, "size must be positive"));
|
||||
@@ -223,6 +229,8 @@ impl CharChange {
|
||||
&& self.strikethrough.is_none()
|
||||
&& self.all_caps.is_none()
|
||||
&& self.features.is_none()
|
||||
&& self.position.is_none()
|
||||
&& self.small_caps.is_none()
|
||||
}
|
||||
|
||||
pub(crate) fn apply(&self, st: &mut CharStyle) {
|
||||
@@ -287,13 +295,19 @@ impl CharChange {
|
||||
if let Some(v) = &self.features {
|
||||
st.features = v.clone();
|
||||
}
|
||||
if let Some(v) = self.position {
|
||||
st.position = v;
|
||||
}
|
||||
if let Some(v) = self.small_caps {
|
||||
st.small_caps = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.setRangeStyle";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let change = CharChange::parse(p, C)?;
|
||||
let change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
|
||||
if change.is_empty() {
|
||||
return Err(bad(C, "nothing to change"));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! commands of the Edit menu (Find and Replace, Find Next, Paste without Formatting).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{CharStyle, Document, Justify, NodeId, NodeKind, TextKind, TextRun};
|
||||
use vectorcraft_doc::{CharStyle, Document, Justify, NodeId, NodeKind, Quotes, TextKind, TextRun};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
use super::typecmd::refresh_bounds;
|
||||
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Smart Punctuation…",
|
||||
["Type"],
|
||||
None,
|
||||
"{quotes?: true (\"\" '' → curly), dashes?: true (-- → en dash, --- → em dash), ellipsis?: true (... → …), scope?: \"selection\"|\"document\"} → {changed}",
|
||||
"{quotes?: true (straight quotes become the Document Setup quotes), dashes?: true (-- → en dash, --- → em dash), ellipsis?: true (... → …), scope?: \"selection\"|\"document\"} → {changed}",
|
||||
has_doc,
|
||||
smart_punctuation
|
||||
),
|
||||
@@ -217,11 +217,8 @@ struct PunctState {
|
||||
prev: Option<char>,
|
||||
}
|
||||
|
||||
fn opens(prev: Option<char>) -> bool {
|
||||
prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–' | '“' | '‘'))
|
||||
}
|
||||
|
||||
pub(crate) fn smarten(text: &str, prev: &mut Option<char>, quotes: bool, dashes: bool, ellipsis: bool) -> String {
|
||||
/// Dashes (`--` en, `---` em), ellipses and, with `quotes`, typographer's quotes.
|
||||
pub(crate) fn smarten(text: &str, prev: &mut Option<char>, quotes: Option<&Quotes>, dashes: bool, ellipsis: bool) -> String {
|
||||
let mut s = text.to_string();
|
||||
if dashes {
|
||||
s = s.replace("---", "—").replace("--", "–");
|
||||
@@ -229,38 +226,20 @@ pub(crate) fn smarten(text: &str, prev: &mut Option<char>, quotes: bool, dashes:
|
||||
if ellipsis {
|
||||
s = s.replace("...", "…");
|
||||
}
|
||||
if !quotes {
|
||||
*prev = s.chars().last().or(*prev);
|
||||
return s;
|
||||
match quotes {
|
||||
Some(q) => q.apply(&s, prev),
|
||||
None => {
|
||||
*prev = s.chars().last().or(*prev);
|
||||
s
|
||||
}
|
||||
}
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
let r = match c {
|
||||
'"' => {
|
||||
if opens(*prev) {
|
||||
'“'
|
||||
} else {
|
||||
'”'
|
||||
}
|
||||
}
|
||||
'\'' => {
|
||||
if opens(*prev) {
|
||||
'‘'
|
||||
} else {
|
||||
'’'
|
||||
}
|
||||
}
|
||||
c => c,
|
||||
};
|
||||
out.push(r);
|
||||
*prev = Some(r);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn smart_punctuation(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (q, d, e) = (bool_or(p, "quotes", true), bool_or(p, "dashes", true), bool_or(p, "ellipsis", true));
|
||||
let (d, e) = (bool_or(p, "dashes", true), bool_or(p, "ellipsis", true));
|
||||
let st = s.doc()?;
|
||||
// Quotes in the document's style (Document Setup → Type).
|
||||
let q = bool_or(p, "quotes", true).then_some(st.doc.setup.quotes);
|
||||
let ids = if str_param(p, "scope") == Some("document") || st.selection.is_empty() {
|
||||
let mut v = vec![];
|
||||
st.doc.walk(|n| {
|
||||
@@ -272,7 +251,7 @@ fn smart_punctuation(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
text_ids(&st.doc, &st.selection.objects)
|
||||
};
|
||||
let n = edit_runs::<PunctState>(s, "Smart Punctuation", &ids, |text, st| smarten(text, &mut st.prev, q, d, e))?;
|
||||
let n = edit_runs::<PunctState>(s, "Smart Punctuation", &ids, |text, st| smarten(text, &mut st.prev, q.as_ref(), d, e))?;
|
||||
Ok(json!({ "changed": n }))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user