mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 14:22:39 +00:00
Merge pull request #2669 from dotCooCoo/feat/object-style-text-frame-options
Object styles: a style sets its frames' paragraph styles and Text Frame General Options
This commit is contained in:
40 files changed
+2548
-116
No files matched your search
@@ -552,6 +552,9 @@ Newest first.
|
||||
- **2026-10-18** · TOC separators preserve right-indent tabs through the dialog and IDML, including localized `$ID/^y` values. A separator picker exposes Tab Character, Right Indent Tab, Em Space and En Space. Existing paragraph tab leaders now work with the tutorial's TOC workflow. No readiness percentage change.
|
||||
- **2026-10-21** · The Package dialog's Include PDF (Print) has a PDF Preset (the Export PDF presets, the last one used first), as InDesign's Select PDF Preset; `file.package` takes `pdf: {…file.exportPdf options}`, refusing bad options before writing anything. The packaged PDF always had the default settings. Found in the Learn Skills Daily course, chapter 27. No readiness percentage change.
|
||||
- **2026-10-21** · File › Package… opens InDesign's Package dialog: Summary counts the fonts, links and images and inks the package carries, with a warning where some are missing or modified, and Fonts, Links and Images and Colors and Inks list them (Show Problems Only). Create Printing Instructions asks for the contact details and instructions that go into the package's Instructions.txt, and Include IDML / Include PDF (Print) choose the copies it carries. Package… went straight to the folder picker with fixed options. Found in the Learn Skills Daily course, chapter 27. No readiness percentage change.
|
||||
- **2026-10-21** · Object styles nest a paragraph style, as InDesign's: Object Style Options › Paragraph Styles chooses the style a text frame's text takes when the object style is applied, and Apply Next Style gives the first paragraph that style and each paragraph after it the Next Style of the one before (`style.object.create` / `edit`: `paragraphStyle`, `applyNextStyle`; IDML `ApplyNextParagraphStyle`). Object Style Options › Text Frame General Options sets columns, insets, vertical justification and Ignore Text Wrap (`textFrame`). Editing either moves the frames that still follow the style; text in another paragraph style is an override that Clear Overrides puts back. Found in the Learn Skills Daily course, chapter 39. No readiness percentage change.
|
||||
- **2026-10-21** · Window › Styles › Object Styles: InDesign's Object Styles panel (the selected object's style, `+` when overridden; a click applies a style, a double-click opens its options; the default graphic and text frame styles marked; Clear Overrides; Create New Style, ⌥/Alt-click for the dialog) and its New Object Style / Object Style Options dialog (Style Name; Basic Attributes Fill, Stroke, Stroke & Corner Options, each included by its checkbox; Effects for Object with the effects' pages; Style Settings; Apply Style to Selection). UI command `objectStyle.dialog`. Found in the Learn Skills Daily course, chapter 15. No readiness percentage change.
|
||||
- **2026-10-21** · Object styles carry Corner Options (`corners`), taken from the selection by `style.object.create` and written to IDML (`EnableStrokeAndCornerOptions`, `TopLeftCornerOption`…). `style.object.edit` also changes a style's fill, stroke and corners and renames it (`rename`); the items using the style that haven't overridden an attribute follow each change, as in InDesign. Found in the Learn Skills Daily course, chapter 15. No readiness percentage change.
|
||||
- **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
|
||||
- **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
|
||||
- **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change.
|
||||
|
||||
@@ -308,6 +308,8 @@ impl Effects {
|
||||
ds.size = len("dropShadow.size", ds.size)?;
|
||||
ds.spread = pct("dropShadow.spread", ds.spread)?;
|
||||
self.feather = len("feather", self.feather)?;
|
||||
// As IDML import reads it, so a round trip keeps it.
|
||||
self.feather_options.choke = pct("featherOptions.choke", self.feather_options.choke)?;
|
||||
let e = &mut self.inner_shadow;
|
||||
e.opacity = opacity("innerShadow.opacity", e.opacity)?;
|
||||
e.angle = finite("innerShadow.angle", e.angle)?;
|
||||
|
||||
@@ -240,6 +240,65 @@ impl Default for TextFrameOptions {
|
||||
}
|
||||
}
|
||||
|
||||
impl TextFrameOptions {
|
||||
/// The JSON keys of the General options: columns, inset spacing, vertical justification and
|
||||
/// Ignore Text Wrap. An object style's Text Frame General Options category sets these only.
|
||||
pub const GENERAL_KEYS: [&str; 9] = [
|
||||
"columns",
|
||||
"columnsKind",
|
||||
"gutter",
|
||||
"columnWidth",
|
||||
"balanceColumns",
|
||||
"inset",
|
||||
"verticalJustification",
|
||||
"vjParagraphSpacingLimit",
|
||||
"ignoreWrap",
|
||||
];
|
||||
|
||||
/// Whether the General options (see [`Self::GENERAL_KEYS`]) of `self` and `other` are the same.
|
||||
pub fn general_eq(&self, other: &TextFrameOptions) -> bool {
|
||||
self.columns == other.columns
|
||||
&& self.columns_kind == other.columns_kind
|
||||
&& self.gutter == other.gutter
|
||||
&& self.column_width == other.column_width
|
||||
&& self.balance_columns == other.balance_columns
|
||||
&& self.inset == other.inset
|
||||
&& self.vertical_justification == other.vertical_justification
|
||||
&& self.vj_paragraph_spacing_limit == other.vj_paragraph_spacing_limit
|
||||
&& self.ignore_wrap == other.ignore_wrap
|
||||
}
|
||||
|
||||
/// Each General option that is `old`'s here takes `new`'s; the others stay. `true` when one
|
||||
/// changed.
|
||||
pub fn follow_general(&mut self, old: &TextFrameOptions, new: &TextFrameOptions) -> bool {
|
||||
let mut changed = false;
|
||||
macro_rules! follow {
|
||||
($($f:ident),*) => {$(
|
||||
if self.$f == old.$f && self.$f != new.$f {
|
||||
self.$f = new.$f;
|
||||
changed = true;
|
||||
}
|
||||
)*};
|
||||
}
|
||||
follow!(columns, columns_kind, gutter, column_width, balance_columns, inset, vertical_justification, vj_paragraph_spacing_limit, ignore_wrap);
|
||||
changed
|
||||
}
|
||||
|
||||
/// Copies the General options of `from` (see [`Self::GENERAL_KEYS`]). Baseline, auto-size,
|
||||
/// column rule and Type on a Path settings stay as they are.
|
||||
pub fn set_general(&mut self, from: &TextFrameOptions) {
|
||||
self.columns = from.columns;
|
||||
self.columns_kind = from.columns_kind;
|
||||
self.gutter = from.gutter;
|
||||
self.column_width = from.column_width;
|
||||
self.balance_columns = from.balance_columns;
|
||||
self.inset = from.inset;
|
||||
self.vertical_justification = from.vertical_justification;
|
||||
self.vj_paragraph_spacing_limit = from.vj_paragraph_spacing_limit;
|
||||
self.ignore_wrap = from.ignore_wrap;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WrapMode {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Paragraph, character and object styles with based-on inheritance.
|
||||
|
||||
use designcraft_geom::corners::CornerOptions;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::attrs::{CharAttrs, CharProps, ParaAttrs, ParaProps};
|
||||
use crate::item::{Effects, Fill, Item, Stroke, TextFrameOptions};
|
||||
use crate::story::{BASIC_PARAGRAPH, CharFormat, NO_CHAR_STYLE, ParaFormat};
|
||||
use crate::story::{BASIC_PARAGRAPH, CharFormat, NO_CHAR_STYLE, ParaFormat, Story};
|
||||
|
||||
pub const NO_PARA_STYLE: &str = "[No Paragraph Style]";
|
||||
pub const BASIC_GRAPHICS_FRAME: &str = "[Basic Graphics Frame]";
|
||||
@@ -53,17 +54,30 @@ pub struct ObjectStyle {
|
||||
pub stroke: Option<Stroke>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub paragraph_style: Option<String>,
|
||||
/// Paragraph Styles › Apply Next Style: the paragraph style goes on a text frame's first
|
||||
/// paragraph, and each paragraph after it takes the Next Style of the one before.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub apply_next_style: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_frame: Option<TextFrameOptions>,
|
||||
/// Effects (drop shadow, feather, glows…). An item using the style follows them until its own
|
||||
/// effects differ from the style's (a local override).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub effects: Option<Effects>,
|
||||
/// Corner Options (Stroke & Corner Options): each corner's shape and size, by name (top left,
|
||||
/// top right, bottom right, bottom left), as InDesign's; an item takes them at the anchors at
|
||||
/// those corners ([`CornerOptions::named_to_path`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub corners: Option<CornerOptions>,
|
||||
}
|
||||
|
||||
impl ObjectStyle {
|
||||
/// Gives `it` the fill, stroke, effects and text frame options this style sets.
|
||||
/// Gives `it` the fill, stroke, corners, effects and text frame General options this style
|
||||
/// sets. A text frame keeps its baseline, auto-size, column rule and Type on a Path settings.
|
||||
pub fn apply_to(&self, it: &mut Item) {
|
||||
if let Some(c) = self.corners {
|
||||
it.corners = c.named_to_path(&it.path);
|
||||
}
|
||||
if let Some(f) = &self.fill {
|
||||
it.fill = f.clone();
|
||||
}
|
||||
@@ -74,9 +88,62 @@ impl ObjectStyle {
|
||||
it.effects = e.clone();
|
||||
}
|
||||
if let (Some(tf), Some(o)) = (it.text_frame_mut(), &self.text_frame) {
|
||||
tf.options = o.clone();
|
||||
tf.options.set_general(o);
|
||||
}
|
||||
}
|
||||
|
||||
/// The paragraph style of each of a text frame's `n` paragraphs under this style: its
|
||||
/// paragraph style for all of them, or with Apply Next Style that one first and then each
|
||||
/// one's Next Style. `None` when the style sets no paragraph style.
|
||||
pub fn paragraph_styles(&self, styles: &Styles, n: usize) -> Option<Vec<String>> {
|
||||
let mut cur = self.paragraph_style.clone()?;
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
out.push(cur.clone());
|
||||
if self.apply_next_style
|
||||
&& let Some(next) = styles.para(&cur).and_then(|s| s.next_style.clone())
|
||||
{
|
||||
cur = next;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Whether a story's paragraphs are in the paragraph styles this style gives them (they
|
||||
/// always are when it sets none).
|
||||
pub fn story_follows(&self, styles: &Styles, st: &Story) -> bool {
|
||||
self.paragraph_styles(styles, st.paras.len()).is_none_or(|want| st.paras.iter().zip(&want).all(|(f, w)| f.style == *w))
|
||||
}
|
||||
|
||||
/// After an edit from `old`: the paragraphs of `st` still in the paragraph style `old` gives
|
||||
/// them (every paragraph, when `old` set none) with the one this style gives each, where that
|
||||
/// differs. A paragraph in another style is left out.
|
||||
pub fn restyle_from(&self, old: &ObjectStyle, styles: &Styles, st: &Story) -> Vec<(usize, String)> {
|
||||
let n = st.paras.len();
|
||||
let Some(want) = self.paragraph_styles(styles, n) else { return Vec::new() };
|
||||
let was = old.paragraph_styles(styles, n);
|
||||
st.paras
|
||||
.iter()
|
||||
.zip(want)
|
||||
.enumerate()
|
||||
.filter(|(i, (f, w))| was.as_ref().is_none_or(|was| f.style == was[*i]) && f.style != *w)
|
||||
.map(|(i, (_, w))| (i, w))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Put a story's paragraphs in the paragraph styles this style gives them; `false` when it
|
||||
/// sets none or they already are.
|
||||
pub fn restyle(&self, styles: &Styles, st: &mut Story) -> bool {
|
||||
if self.story_follows(styles, st) {
|
||||
return false;
|
||||
}
|
||||
let Some(want) = self.paragraph_styles(styles, st.paras.len()) else { return false };
|
||||
for (f, w) in st.paras.iter_mut().zip(want) {
|
||||
f.style = w;
|
||||
}
|
||||
st.rev = st.rev.wrapping_add(1);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -173,7 +173,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Apply Object Style",
|
||||
[],
|
||||
None,
|
||||
"{name, ids?} — sets the style's fill, stroke, effects and text frame options on the items",
|
||||
"{name, ids?} — sets the style's fill, stroke, corners, effects and text frame General options on the items, and its paragraph styles on their text; records nothing when the items already have them",
|
||||
super::has_selection,
|
||||
apply_object
|
||||
),
|
||||
@@ -182,7 +182,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Object Style…",
|
||||
[],
|
||||
None,
|
||||
"{name, fromSelection?: true (fill, stroke, effects and text frame options of the first selected item), fill?, paragraphStyle?, effects?: {dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?}, dropShadow?: {on?, color?, opacity? (0–1), angle?, globalLight?, distance?, size?, spread?}, feather?: width, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} → {name} (made unique)",
|
||||
"{name, fromSelection?: true (fill, stroke, corners, effects and text frame options of the first selected item), fill?: swatch | {swatch?, tint?}, stroke?: {swatch?, weight?, tint?, type?, …} (swatches must exist; unknown fill and stroke keys are refused), corners?: {shape?, size?} | {corners: [{shape, size}] ×4}, paragraphStyle?: a paragraph style (null: none; the text of a frame the style is applied to takes it), applyNextStyle?: bool (each paragraph after the first takes the Next Style of the one before), textFrame?: {columns? (1–40), columnsKind?, gutter?, columnWidth?, balanceColumns?, inset?: [top, left, bottom, right] | one number for all four, verticalJustification?: top|center|bottom|justify, vjParagraphSpacingLimit?, ignoreWrap?} | null (Text Frame General Options, named as `document.inspect` names text frame options; lengths 0–15552 pt; other keys are refused; a frame keeps its baseline, auto-size and column rule settings; null: the style sets none), effects?: {dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?}, dropShadow?: {on?, color?, opacity? (0–1), angle?, globalLight?, distance?, size?, spread?}, feather?: width, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} → {name} (made unique)",
|
||||
has_doc,
|
||||
create_object
|
||||
),
|
||||
@@ -191,7 +191,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Object Style Options…",
|
||||
[],
|
||||
None,
|
||||
"{name, effects?: {…} | null (the style no longer sets effects), dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} — effect objects merge field by field into the style's effects (fields as in `document.inspect` item effects); values that aren't finite numbers are refused, the others clamped (opacities 0–1, sizes and distances ≥ 0); items using the style whose effects match the style's (no effects, when the style set none) take the new effects, items with their own effects keep them → {name, updated}",
|
||||
"{name, rename?: new name (every use follows), fill?: swatch | {swatch?, tint?} | null, stroke?: {swatch?, weight?, tint?, type?, …} | null (swatches must exist; unknown fill and stroke keys are refused), corners?: {shape?: none|rounded|inverseRounded|inset|bevel|fancy, size?} | {corners: [{shape, size}] ×4} | null (null: the style no longer sets it; items keep theirs), paragraphStyle?: a paragraph style (null: none; the text of a frame the style is applied to takes it), applyNextStyle?: bool (each paragraph after the first takes the Next Style of the one before), textFrame?: {columns? (1–40), columnsKind?, gutter?, columnWidth?, balanceColumns?, inset?: [top, left, bottom, right] | one number for all four, verticalJustification?: top|center|bottom|justify, vjParagraphSpacingLimit?, ignoreWrap?} | null (Text Frame General Options, named as `document.inspect` names text frame options; lengths 0–15552 pt; other keys are refused; a frame keeps its baseline, auto-size and column rule settings; null: the style sets none), effects?: {…} | null (the style no longer sets effects), dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} — effect objects merge field by field into the style's effects (fields as in `document.inspect` item effects); values that aren't finite numbers are refused, the others clamped (opacities 0–1, sizes and distances ≥ 0); items using the style whose effects (fill, stroke, corners) match the style's (an item's defaults, when the style set none) take the new ones, items with their own keep them; the text of frames still in the old paragraph styles takes the new ones → {name, updated, restyled}",
|
||||
has_doc,
|
||||
edit_object
|
||||
),
|
||||
@@ -200,7 +200,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Clear Object Style Overrides",
|
||||
[],
|
||||
None,
|
||||
"{ids?} — each item takes its object style's fill, stroke, effects and text frame options again → {changed}",
|
||||
"{ids?} — each item takes its object style's fill, stroke, effects and text frame options again, and a text frame's story its paragraph style → {changed}",
|
||||
super::has_selection,
|
||||
clear_object_overrides
|
||||
),
|
||||
|
||||
@@ -4,6 +4,18 @@ pub(super) fn apply_object(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("style.object.apply", "missing name"))?.to_string();
|
||||
let os = s.doc()?.doc.styles.object_style(&name).cloned().ok_or_else(|| bad("style.object.apply", format!("no object style `{name}`")))?;
|
||||
let ids = crate::cmd::targets(s, p)?;
|
||||
// Nothing to record when every target already has the style and its attributes, and its text
|
||||
// the style's paragraph styles.
|
||||
let d = &s.doc()?.doc;
|
||||
let unchanged = ids.iter().filter_map(|id| d.item(*id)).all(|it| {
|
||||
let mut next = it.clone();
|
||||
next.object_style = name.clone();
|
||||
os.apply_to(&mut next);
|
||||
next == *it && it.text_frame().and_then(|tf| d.story(tf.story)).is_none_or(|st| os.story_follows(&d.styles, st))
|
||||
});
|
||||
if unchanged {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
let mut stories = Vec::new();
|
||||
for id in &ids {
|
||||
@@ -14,12 +26,11 @@ pub(super) fn apply_object(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
stories.push(tf.story);
|
||||
}
|
||||
}
|
||||
if let Some(ps) = &os.paragraph_style {
|
||||
for sid in stories {
|
||||
if let Some(st) = d.story_mut(sid) {
|
||||
let len = st.len();
|
||||
st.format_paras(0..len, |f| f.style = ps.clone());
|
||||
}
|
||||
// The text takes the style's paragraph style (with Apply Next Style, a chain of them).
|
||||
let styles = d.styles.clone();
|
||||
for sid in stories {
|
||||
if let Some(st) = d.story_mut(sid) {
|
||||
os.restyle(&styles, st);
|
||||
}
|
||||
}
|
||||
Ok(Value::Null)
|
||||
@@ -28,18 +39,33 @@ pub(super) fn apply_object(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
pub(super) fn clear_object_overrides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = crate::cmd::targets(s, p)?;
|
||||
// Each target with its style's attributes applied, kept only where that changes the item.
|
||||
// Each target with its style's attributes applied, kept only where that changes the item;
|
||||
// and a text frame's story back in the style's paragraph style, as applying the style does.
|
||||
let d = &s.doc()?.doc;
|
||||
let changed: Vec<Item> = ids
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let it = d.item(*id)?;
|
||||
let mut next = it.clone();
|
||||
d.styles.object_style(&it.object_style)?.apply_to(&mut next);
|
||||
(next != *it).then_some(next)
|
||||
})
|
||||
.collect();
|
||||
if changed.is_empty() {
|
||||
let mut changed: Vec<Item> = Vec::new();
|
||||
let mut restyled: Vec<(designcraft_doc::StoryId, ObjectStyle)> = Vec::new();
|
||||
let mut n = 0;
|
||||
for id in &ids {
|
||||
let Some(it) = d.item(*id) else { continue };
|
||||
let Some(os) = d.styles.object_style(&it.object_style) else { continue };
|
||||
let mut next = it.clone();
|
||||
os.apply_to(&mut next);
|
||||
let item_changed = next != *it;
|
||||
if item_changed {
|
||||
changed.push(next);
|
||||
}
|
||||
let mut story_changed = false;
|
||||
if let Some(tf) = it.text_frame()
|
||||
&& d.story(tf.story).is_some_and(|st| !os.story_follows(&d.styles, st))
|
||||
{
|
||||
story_changed = true;
|
||||
if !restyled.iter().any(|(sid, _)| *sid == tf.story) {
|
||||
restyled.push((tf.story, os.clone()));
|
||||
}
|
||||
}
|
||||
n += usize::from(item_changed || story_changed);
|
||||
}
|
||||
if n == 0 {
|
||||
return Ok(json!({"changed": 0}));
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
@@ -48,7 +74,13 @@ pub(super) fn clear_object_overrides(s: &mut Session, p: &Value) -> Result<Value
|
||||
*it = next.clone();
|
||||
}
|
||||
}
|
||||
Ok(json!({"changed": changed.len()}))
|
||||
let styles = d.styles.clone();
|
||||
for (sid, os) in &restyled {
|
||||
if let Some(st) = d.story_mut(*sid) {
|
||||
os.restyle(&styles, st);
|
||||
}
|
||||
}
|
||||
Ok(json!({"changed": n}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -99,20 +131,35 @@ fn merge_json(base: &mut Value, top: Value, depth: usize) {
|
||||
|
||||
pub(super) fn create_object(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = str_param(p, "name").unwrap_or("Object Style 1").to_string();
|
||||
let from_sel = p.get("fromSelection").and_then(Value::as_bool).unwrap_or(true);
|
||||
const CREATE: &str = "style.object.create";
|
||||
check_swatches(CREATE, s, p)?;
|
||||
let from_sel = bool_param(CREATE, p, "fromSelection")?.unwrap_or(true);
|
||||
let src = if from_sel { s.doc()?.selection.items.first().and_then(|i| s.doc().ok()?.doc.item(*i).cloned()) } else { None };
|
||||
let mut os = ObjectStyle::default();
|
||||
if let Some(it) = &src {
|
||||
os.fill = Some(it.fill.clone());
|
||||
os.stroke = Some(it.stroke.clone());
|
||||
os.corners = Some(it.corners.path_to_named(&it.path));
|
||||
os.text_frame = it.text_frame().map(|t| t.options.clone());
|
||||
os.effects = Some(it.effects.clone());
|
||||
}
|
||||
if let Some(sw) = str_param(p, "fill") {
|
||||
os.fill = Some(designcraft_doc::Fill::swatch(sw));
|
||||
if let Some(f) = fill_param(CREATE, p, os.fill.as_ref())? {
|
||||
os.fill = f;
|
||||
}
|
||||
if let Some(ps) = str_param(p, "paragraphStyle") {
|
||||
os.paragraph_style = Some(ps.to_string());
|
||||
if let Some(st) = stroke_param(CREATE, p, os.stroke.as_ref())? {
|
||||
os.stroke = st;
|
||||
}
|
||||
if let Some(c) = corners_param(CREATE, p, os.corners.unwrap_or_default())? {
|
||||
os.corners = c;
|
||||
}
|
||||
if let Some(ps) = paragraph_style_param(CREATE, s, p)? {
|
||||
os.paragraph_style = ps;
|
||||
}
|
||||
if let Some(next) = bool_param(CREATE, p, "applyNextStyle")? {
|
||||
os.apply_next_style = next;
|
||||
}
|
||||
if let Some(tf) = text_frame_param(CREATE, p, os.text_frame.as_ref())? {
|
||||
os.text_frame = tf;
|
||||
}
|
||||
if let Some(e) = effects_param("style.object.create", p, os.effects.as_ref().unwrap_or(&Effects::default()))? {
|
||||
os.effects = Some(e);
|
||||
@@ -125,6 +172,166 @@ pub(super) fn create_object(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The swatches this call gives (`fill` as a name or `fill.swatch`, `stroke.swatch` and
|
||||
/// `stroke.gapSwatch`) must be swatches of the document.
|
||||
fn check_swatches(cmd: &str, s: &Session, p: &Value) -> Result<()> {
|
||||
let doc = &s.doc()?.doc;
|
||||
let given = [
|
||||
("fill", p.get("fill").and_then(|f| f.as_str().or_else(|| f.get("swatch").and_then(Value::as_str)))),
|
||||
("stroke", p.get("stroke").and_then(|f| f.get("swatch")).and_then(Value::as_str)),
|
||||
("stroke gap", p.get("stroke").and_then(|f| f.get("gapSwatch")).and_then(Value::as_str)),
|
||||
];
|
||||
for (key, name) in given {
|
||||
if let Some(name) = name {
|
||||
crate::cmd::object::known_swatch(doc, name, cmd, key)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `paragraphStyle`: a paragraph style's name, or null (the object style sets none).
|
||||
fn paragraph_style_param(cmd: &str, s: &Session, p: &Value) -> Result<Option<Option<String>>> {
|
||||
match p.get("paragraphStyle") {
|
||||
None => Ok(None),
|
||||
Some(Value::Null) => Ok(Some(None)),
|
||||
Some(Value::String(n)) if s.doc()?.doc.styles.para(n).is_some() => Ok(Some(Some(n.clone()))),
|
||||
Some(Value::String(n)) => Err(bad(cmd, format!("no paragraph style `{n}`"))),
|
||||
Some(_) => Err(bad(cmd, "`paragraphStyle` must be a paragraph style's name or null")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `textFrame` (a text frame's General options, as `document.inspect` shows them) merged over
|
||||
/// `base`, or null; refused when it names another key, the columns aren't 1–40 or a gutter, width
|
||||
/// or inset isn't 0–15552 pt. `inset` is `[top, left, bottom, right]` or one number for all four.
|
||||
fn text_frame_param(
|
||||
cmd: &str,
|
||||
p: &Value,
|
||||
base: Option<&designcraft_doc::TextFrameOptions>,
|
||||
) -> Result<Option<Option<designcraft_doc::TextFrameOptions>>> {
|
||||
let general = designcraft_doc::TextFrameOptions::GENERAL_KEYS;
|
||||
let mut p = p.clone();
|
||||
if let Some(Value::Object(o)) = p.get_mut("textFrame") {
|
||||
if let Some(k) = o.keys().find(|k| !general.contains(&k.as_str())) {
|
||||
return Err(bad(cmd, format!("textFrame.{k} is not a General option ({})", general.join(", "))));
|
||||
}
|
||||
if let Some(n) = o.get("inset").filter(|v| v.is_number()).cloned() {
|
||||
o.insert("inset".into(), json!([n, n, n, n]));
|
||||
}
|
||||
}
|
||||
let tf = group_param(cmd, &p, "textFrame", base.unwrap_or(&designcraft_doc::TextFrameOptions::default()), &[])?;
|
||||
if let Some(Some(o)) = &tf {
|
||||
// Only the values given in this call are checked.
|
||||
let given = |k: &str| p.get("textFrame").is_some_and(|t| t.get(k).is_some());
|
||||
if given("columns") && !(1..=40).contains(&o.columns) {
|
||||
return Err(bad(cmd, "text frame columns must be 1–40"));
|
||||
}
|
||||
let lengths = [("gutter", o.gutter), ("columnWidth", o.column_width), ("vjParagraphSpacingLimit", o.vj_paragraph_spacing_limit)];
|
||||
let mut lengths = lengths.into_iter().filter(|(k, _)| given(k)).map(|(_, v)| v).chain(o.inset.into_iter().filter(|_| given("inset")));
|
||||
if !lengths.all(|v| v.is_finite() && (0.0..=15552.0).contains(&v)) {
|
||||
return Err(bad(cmd, "a text frame's gutter, widths and insets must be 0–15552 pt"));
|
||||
}
|
||||
}
|
||||
Ok(tf)
|
||||
}
|
||||
|
||||
/// A true/false parameter, when given.
|
||||
fn bool_param(cmd: &str, p: &Value, key: &str) -> Result<Option<bool>> {
|
||||
match p.get(key) {
|
||||
None => Ok(None),
|
||||
Some(Value::Bool(b)) => Ok(Some(*b)),
|
||||
Some(_) => Err(bad(cmd, format!("`{key}` must be true or false"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `p.stroke` merged over `base` (as [`group_param`]), refused when the weight isn't a number ≥ 0
|
||||
/// or the tint isn't 0–1.
|
||||
fn stroke_param(cmd: &str, p: &Value, base: Option<&designcraft_doc::Stroke>) -> Result<Option<Option<designcraft_doc::Stroke>>> {
|
||||
let optional = ["gapOverprint", "gradientAngle", "gradientVector"];
|
||||
let st = group_param(cmd, p, "stroke", base.unwrap_or(&designcraft_doc::Stroke::default()), &optional)?;
|
||||
if let Some(Some(st)) = &st {
|
||||
if !(st.weight.is_finite() && st.weight >= 0.0) {
|
||||
return Err(bad(cmd, "stroke weight must be a number ≥ 0"));
|
||||
}
|
||||
if !(0.0..=1.0).contains(&st.tint) {
|
||||
return Err(bad(cmd, "stroke tint must be 0..1"));
|
||||
}
|
||||
}
|
||||
Ok(st)
|
||||
}
|
||||
|
||||
/// `p[key]`, an attribute group of a style, merged field by field over `base`: `None` when not
|
||||
/// given, `Some(None)` for null (the style no longer sets it). A field `base` doesn't write and
|
||||
/// `optional` doesn't name is refused.
|
||||
fn group_param<T: serde::Serialize + serde::de::DeserializeOwned>(
|
||||
cmd: &str,
|
||||
p: &Value,
|
||||
key: &str,
|
||||
base: &T,
|
||||
optional: &[&str],
|
||||
) -> Result<Option<Option<T>>> {
|
||||
match p.get(key) {
|
||||
None => Ok(None),
|
||||
Some(Value::Null) => Ok(Some(None)),
|
||||
Some(v @ Value::Object(given)) => {
|
||||
let mut cur = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
if let Some(k) = given.keys().find(|k| cur.get(k.as_str()).is_none() && !optional.contains(&k.as_str())) {
|
||||
return Err(bad(cmd, format!("{key}.{k} is not a {key} setting")));
|
||||
}
|
||||
merge_json(&mut cur, v.clone(), 0);
|
||||
serde_json::from_value(cur).map(|t| Some(Some(t))).map_err(|e| bad(cmd, format!("{key}: {e}")))
|
||||
}
|
||||
Some(_) => Err(bad(cmd, format!("`{key}` must be an object or null"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `fill`: a swatch name, `{swatch?, tint?}` over `base`, or null.
|
||||
fn fill_param(cmd: &str, p: &Value, base: Option<&designcraft_doc::Fill>) -> Result<Option<Option<designcraft_doc::Fill>>> {
|
||||
if let Some(sw) = p.get("fill").and_then(Value::as_str) {
|
||||
return Ok(Some(Some(designcraft_doc::Fill::swatch(sw))));
|
||||
}
|
||||
let fill = group_param(cmd, p, "fill", base.unwrap_or(&designcraft_doc::Fill::default()), &["gradientAngle", "gradientVector"])?;
|
||||
if let Some(Some(f)) = &fill
|
||||
&& !(f.tint.is_finite() && (0.0..=1.0).contains(&f.tint))
|
||||
{
|
||||
return Err(bad(cmd, "fill tint must be 0–1"));
|
||||
}
|
||||
Ok(fill)
|
||||
}
|
||||
|
||||
/// `corners`: `{shape?, size?}` for all four, `{corners: [{shape, size}] ×4}`, or null.
|
||||
fn corners_param(
|
||||
cmd: &str,
|
||||
p: &Value,
|
||||
base: designcraft_geom::corners::CornerOptions,
|
||||
) -> Result<Option<Option<designcraft_geom::corners::CornerOptions>>> {
|
||||
let Some(v) = p.get("corners") else { return Ok(None) };
|
||||
if v.is_null() {
|
||||
return Ok(Some(None));
|
||||
}
|
||||
let o = v.as_object().ok_or_else(|| bad(cmd, "`corners` must be an object or null"))?;
|
||||
if let Some(k) = o.keys().find(|k| !["shape", "size", "corners"].contains(&k.as_str())) {
|
||||
return Err(bad(cmd, format!("corners.{k} is not a corner setting (shape, size, corners)")));
|
||||
}
|
||||
let mut c = if o.contains_key("corners") {
|
||||
serde_json::from_value::<designcraft_geom::corners::CornerOptions>(v.clone()).map_err(|e| bad(cmd, format!("corners: {e}")))?
|
||||
} else {
|
||||
base
|
||||
};
|
||||
if let Some(sh) = o.get("shape") {
|
||||
let shape = serde_json::from_value(sh.clone()).map_err(|e| bad(cmd, format!("corner shape: {e}")))?;
|
||||
c.corners.iter_mut().for_each(|k| k.shape = shape);
|
||||
}
|
||||
if let Some(sz) = o.get("size") {
|
||||
let size = sz.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, "corner size must be a number"))?;
|
||||
c.corners.iter_mut().for_each(|k| k.size = size);
|
||||
}
|
||||
// Sizes given in this call must be 0–10000 pt.
|
||||
if (o.contains_key("corners") || o.contains_key("size")) && !c.corners.iter().all(|k| k.size.is_finite() && (0.0..=10_000.0).contains(&k.size)) {
|
||||
return Err(bad(cmd, "corner sizes must be 0–10000 pt"));
|
||||
}
|
||||
Ok(Some(Some(c)))
|
||||
}
|
||||
|
||||
pub(super) fn edit_object(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "style.object.edit";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(CMD, "missing name"))?.to_string();
|
||||
@@ -132,33 +339,153 @@ pub(super) fn edit_object(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(CMD, format!("`{name}` can't be edited")));
|
||||
}
|
||||
let old = s.doc()?.doc.styles.object_style(&name).cloned().ok_or_else(|| bad(CMD, format!("no object style `{name}`")))?;
|
||||
let effects = if p.get("effects").is_some_and(Value::is_null) {
|
||||
check_swatches(CMD, s, p)?;
|
||||
let mut new = old.clone();
|
||||
if p.get("effects").is_some_and(Value::is_null) {
|
||||
if EFFECT_KEYS.iter().any(|k| p.get(k).is_some()) {
|
||||
return Err(bad(CMD, "`effects: null` can't be combined with effect settings"));
|
||||
}
|
||||
None
|
||||
} else {
|
||||
match effects_param(CMD, p, old.effects.as_ref().unwrap_or(&Effects::default()))? {
|
||||
Some(e) => Some(e),
|
||||
None => return Ok(json!({"name": name, "updated": 0})),
|
||||
new.effects = None;
|
||||
} else if let Some(e) = effects_param(CMD, p, old.effects.as_ref().unwrap_or(&Effects::default()))? {
|
||||
new.effects = Some(e);
|
||||
}
|
||||
if let Some(f) = fill_param(CMD, p, old.fill.as_ref())? {
|
||||
new.fill = f;
|
||||
}
|
||||
if let Some(st) = stroke_param(CMD, p, old.stroke.as_ref())? {
|
||||
new.stroke = st;
|
||||
}
|
||||
if let Some(c) = corners_param(CMD, p, old.corners.unwrap_or_default())? {
|
||||
new.corners = c;
|
||||
}
|
||||
if let Some(ps) = paragraph_style_param(CMD, s, p)? {
|
||||
new.paragraph_style = ps;
|
||||
}
|
||||
if let Some(next) = bool_param(CMD, p, "applyNextStyle")? {
|
||||
new.apply_next_style = next;
|
||||
}
|
||||
if let Some(tf) = text_frame_param(CMD, p, old.text_frame.as_ref())? {
|
||||
new.text_frame = tf;
|
||||
}
|
||||
if p.get("rename").is_some_and(|r| !r.is_string()) {
|
||||
return Err(bad(CMD, "`rename` must be a style name"));
|
||||
}
|
||||
let rename = str_param(p, "rename").map(str::trim).filter(|r| *r != name).map(str::to_string);
|
||||
if let Some(r) = &rename {
|
||||
let builtin = name.starts_with('[') && name.ends_with(']');
|
||||
if builtin || r.is_empty() || (r.starts_with('[') && r.ends_with(']')) {
|
||||
return Err(bad(CMD, format!("`{name}` can't be renamed to `{r}`")));
|
||||
}
|
||||
};
|
||||
if effects == old.effects {
|
||||
if s.doc()?.doc.styles.object_style(r).is_some() {
|
||||
return Err(bad(CMD, format!("an object style is already named `{r}`")));
|
||||
}
|
||||
new.name = r.clone();
|
||||
}
|
||||
if new == old {
|
||||
return Ok(json!({"name": name, "updated": 0}));
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
let Some(os) = d.styles_mut().object.iter_mut().find(|o| o.name == name) else {
|
||||
return Err(bad(CMD, format!("no object style `{name}`")));
|
||||
};
|
||||
os.effects = effects.clone();
|
||||
// An item follows the style while its effects are the style's. When the style didn't set
|
||||
// effects before, only items without effects of their own follow (the default styles are
|
||||
// on most frames).
|
||||
let follows = old.effects.clone().unwrap_or_default();
|
||||
let updated = match &effects {
|
||||
Some(new) => d.update_items(|it| it.object_style == name && it.effects == follows, |it| it.effects = new.clone()),
|
||||
None => 0,
|
||||
*os = new.clone();
|
||||
if let Some(r) = &rename {
|
||||
let st = d.styles_mut();
|
||||
for o in &mut st.object {
|
||||
if o.based_on.as_deref() == Some(name.as_str()) {
|
||||
o.based_on = Some(r.clone());
|
||||
}
|
||||
}
|
||||
for n in [&mut st.default_text_frame, &mut st.default_graphic_frame] {
|
||||
if *n == name {
|
||||
*n = r.clone();
|
||||
}
|
||||
}
|
||||
d.update_placed_items(|it| it.object_style == name, |it| it.object_style = r.clone());
|
||||
}
|
||||
// An item follows each attribute the style sets while it has the style's value. When the
|
||||
// style didn't set it before, items with the default follow (the default styles are on
|
||||
// most frames). An attribute the style no longer sets stays as it is on the items.
|
||||
let effects = (new.effects != old.effects).then_some(new.effects.clone()).flatten().map(|e| (old.effects.clone().unwrap_or_default(), e));
|
||||
let fill = (new.fill != old.fill).then_some(new.fill.clone()).flatten().map(|f| (old.fill.clone().unwrap_or_default(), f));
|
||||
let stroke = (new.stroke != old.stroke).then_some(new.stroke.clone()).flatten().map(|f| (old.stroke.clone(), f));
|
||||
// With no stroke in the style before, an item with no stroke (a frame's default) or a
|
||||
// 1 pt black one (a drawn shape's default) follows.
|
||||
let stroke_follows = |it: &Item, old: &Option<designcraft_doc::Stroke>| match old {
|
||||
Some(o) => it.stroke == *o,
|
||||
None => {
|
||||
it.stroke == designcraft_doc::Stroke::none()
|
||||
|| (matches!(it.content, designcraft_doc::Content::Unassigned) && it.stroke == designcraft_doc::Stroke::default())
|
||||
}
|
||||
};
|
||||
Ok(json!({"name": name, "updated": updated}))
|
||||
let corners = (new.corners != old.corners).then_some(new.corners).flatten().map(|c| (old.corners.unwrap_or_default(), c));
|
||||
let frame =
|
||||
(new.text_frame != old.text_frame).then_some(new.text_frame.clone()).flatten().map(|t| (old.text_frame.clone().unwrap_or_default(), t));
|
||||
let follows = |it: &Item| {
|
||||
effects.as_ref().is_some_and(|(o, _)| it.effects == *o)
|
||||
|| fill.as_ref().is_some_and(|(o, _)| it.fill == *o)
|
||||
|| stroke.as_ref().is_some_and(|(o, _)| stroke_follows(it, o))
|
||||
|| corners.as_ref().is_some_and(|(o, _)| it.corners == o.named_to_path(&it.path))
|
||||
|| frame.as_ref().is_some_and(|(o, n)| it.text_frame().is_some_and(|t| t.options.clone().follow_general(o, n)))
|
||||
};
|
||||
let updated = d.update_placed_items(
|
||||
|it| it.object_style == new.name && follows(it),
|
||||
|it| {
|
||||
if let Some((o, n)) = &effects
|
||||
&& it.effects == *o
|
||||
{
|
||||
it.effects = n.clone();
|
||||
}
|
||||
if let Some((o, n)) = &fill
|
||||
&& it.fill == *o
|
||||
{
|
||||
it.fill = n.clone();
|
||||
}
|
||||
if let Some((o, n)) = &stroke
|
||||
&& stroke_follows(it, o)
|
||||
{
|
||||
it.stroke = n.clone();
|
||||
}
|
||||
if let Some((o, n)) = &corners
|
||||
&& it.corners == o.named_to_path(&it.path)
|
||||
{
|
||||
it.corners = n.named_to_path(&it.path);
|
||||
}
|
||||
// Each General option follows on its own, so a frame's own inset stays while a
|
||||
// new column count reaches it.
|
||||
if let Some((o, n)) = &frame
|
||||
&& let Some(tf) = it.text_frame_mut()
|
||||
{
|
||||
tf.options.follow_general(o, n);
|
||||
}
|
||||
},
|
||||
);
|
||||
// Each paragraph of the frames using the style takes the new paragraph style (or chain)
|
||||
// while it is in the old one's; when the style set none before, every paragraph does.
|
||||
let mut restyled = 0;
|
||||
if new.paragraph_style.is_some() && (new.paragraph_style != old.paragraph_style || new.apply_next_style != old.apply_next_style) {
|
||||
// Frames on spreads and parents, in groups, and anchored in stories, table cells and
|
||||
// notes (as IDML import places them).
|
||||
let mut stories: Vec<_> =
|
||||
d.placed_items().into_iter().filter(|i| i.object_style == new.name).filter_map(|i| i.text_frame().map(|tf| tf.story)).collect();
|
||||
stories.sort_unstable();
|
||||
stories.dedup();
|
||||
let styles = d.styles.clone();
|
||||
for sid in stories {
|
||||
let changes = d.story(sid).map(|st| new.restyle_from(&old, &styles, st)).unwrap_or_default();
|
||||
if !changes.is_empty()
|
||||
&& let Some(st) = d.story_mut(sid)
|
||||
{
|
||||
for (i, w) in changes {
|
||||
if let Some(f) = st.paras.get_mut(i) {
|
||||
f.style = w;
|
||||
}
|
||||
}
|
||||
st.rev = st.rev.wrapping_add(1);
|
||||
restyled += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"name": new.name, "updated": updated, "restyled": restyled}))
|
||||
})
|
||||
}
|
||||
@@ -752,6 +752,316 @@ fn object_style_effects_follow_the_style_until_overridden() {
|
||||
assert_eq!(s.doc().unwrap().revision, rev);
|
||||
}
|
||||
|
||||
/// `style.object.create` and `edit` refuse an unknown swatch, a misspelled setting, a word or null
|
||||
/// for a switch, a rename that isn't a name and a corner size outside 0–10000 pt.
|
||||
#[test]
|
||||
fn object_style_parameters_are_checked() {
|
||||
let mut s = session();
|
||||
s.execute("style.object.create", &json!({"name": "Framed", "fromSelection": false})).unwrap();
|
||||
for bad in [
|
||||
json!({"fill": "Nope"}),
|
||||
json!({"fill": {"swatch": "Nope"}}),
|
||||
json!({"stroke": {"swatch": "Nope"}}),
|
||||
json!({"stroke": {"wieght": 5}}),
|
||||
json!({"fill": {"tnt": 0.5}}),
|
||||
json!({"corners": {"shpae": "rounded"}}),
|
||||
json!({"corners": {"size": -1}}),
|
||||
json!({"corners": {"size": 10_001}}),
|
||||
json!({"applyNextStyle": null}),
|
||||
] {
|
||||
let mut p = bad.clone();
|
||||
p["name"] = json!("Framed");
|
||||
assert!(s.execute("style.object.edit", &p).is_err(), "{bad}");
|
||||
p["name"] = json!("Other");
|
||||
assert!(s.execute("style.object.create", &p).is_err(), "{bad}");
|
||||
}
|
||||
let c = json!({"shape": "rounded", "size": -1});
|
||||
assert!(s.execute("style.object.create", &json!({"name": "Odd", "corners": {"corners": [c, c, c, c]}})).is_err());
|
||||
for from in [json!("yes"), json!(null)] {
|
||||
assert!(s.execute("style.object.create", &json!({"name": "Other", "fromSelection": from})).is_err(), "{from}");
|
||||
}
|
||||
for rename in [json!(3), json!(null), json!(true)] {
|
||||
assert!(s.execute("style.object.edit", &json!({"name": "Framed", "rename": rename})).is_err(), "{rename}");
|
||||
}
|
||||
let names: Vec<_> = s.doc().unwrap().doc.styles.object.iter().map(|o| o.name.clone()).collect();
|
||||
assert!(!names.iter().any(|n| n == "Other" || n == "Odd"), "{names:?}");
|
||||
}
|
||||
|
||||
/// The course's "Rounded Corner Image, White Stroke" style: made from a frame with rounded corners
|
||||
/// and a paper stroke, applied to another frame, then edited (a thicker stroke, bigger corners):
|
||||
/// the frames that follow the style change, one with its own stroke keeps it; a rename follows.
|
||||
#[test]
|
||||
fn object_styles_carry_corners_and_their_stroke_and_corners_can_be_edited() {
|
||||
use designcraft_doc::ItemId;
|
||||
use designcraft_geom::corners::CornerShape;
|
||||
let mut s = session();
|
||||
let frame = |s: &mut Session, x: f64| {
|
||||
s.execute("frame.create", &json!({"rect": [x, 36, x + 100.0, 136], "content": "graphic"})).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let item = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().clone();
|
||||
let a = frame(&mut s, 36.0);
|
||||
s.execute("object.cornerOptions", &json!({"shape": "rounded", "size": 18, "ids": [a]})).unwrap();
|
||||
s.execute("object.stroke", &json!({"swatch": "[Paper]", "weight": 2.5, "ids": [a]})).unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [a]})).unwrap();
|
||||
let name =
|
||||
s.execute("style.object.create", &json!({"name": "Rounded Corner Image, White Stroke"})).unwrap()["name"].as_str().unwrap().to_string();
|
||||
let os = s.doc().unwrap().doc.styles.object_style(&name).cloned().unwrap();
|
||||
assert_eq!(os.corners.unwrap().corners[0].shape, CornerShape::Rounded);
|
||||
let (b, c) = (frame(&mut s, 200.0), frame(&mut s, 360.0));
|
||||
s.execute("style.object.apply", &json!({"name": name, "ids": [a, b, c]})).unwrap();
|
||||
assert_eq!((item(&s, b).corners, item(&s, b).stroke.weight, item(&s, b).stroke.swatch.as_str()), (item(&s, a).corners, 2.5, "[Paper]"));
|
||||
s.execute("object.stroke", &json!({"weight": 1, "ids": [c]})).unwrap();
|
||||
|
||||
let r = s.execute("style.object.edit", &json!({"name": name, "stroke": {"weight": 6}, "corners": {"size": 30}})).unwrap();
|
||||
assert_eq!(r["updated"], 3);
|
||||
assert_eq!((item(&s, a).stroke.weight, item(&s, b).stroke.weight, item(&s, c).stroke.weight), (6.0, 6.0, 1.0));
|
||||
assert_eq!(item(&s, b).stroke.swatch, "[Paper]");
|
||||
assert!(item(&s, c).corners.corners.iter().all(|k| k.shape == CornerShape::Rounded && k.size == 30.0), "corners follow apart from the stroke");
|
||||
s.execute("style.object.edit", &json!({"name": name, "fill": "[Black]"})).unwrap();
|
||||
assert_eq!(item(&s, b).fill.swatch, "[Black]");
|
||||
|
||||
// Renamed, the items keep the style; a name in use or a built-in one is refused.
|
||||
s.execute("style.object.edit", &json!({"name": name, "rename": "Framed"})).unwrap();
|
||||
assert_eq!(item(&s, a).object_style, "Framed");
|
||||
assert!(s.doc().unwrap().doc.styles.object_style(&name).is_none());
|
||||
assert!(s.execute("style.object.edit", &json!({"name": "Framed", "rename": "[Basic Text Frame]"})).is_err());
|
||||
assert!(s.execute("style.object.edit", &json!({"name": "[Basic Graphics Frame]", "rename": "Mine"})).is_err());
|
||||
assert!(s.execute("style.object.edit", &json!({"name": "Framed", "corners": {"size": "big"}})).is_err());
|
||||
// A negative stroke weight is refused when creating a style as when editing one.
|
||||
let styles = s.doc().unwrap().doc.styles.object.len();
|
||||
for cmd in ["style.object.create", "style.object.edit"] {
|
||||
let e = s.execute(cmd, &json!({"name": "Framed", "stroke": {"weight": -3}})).unwrap_err();
|
||||
assert!(e.to_string().contains("stroke weight"), "{cmd}: {e}");
|
||||
for tint in [-1.0, 1.5] {
|
||||
let e = s.execute(cmd, &json!({"name": "Framed", "stroke": {"tint": tint}})).unwrap_err();
|
||||
assert!(e.to_string().contains("stroke tint"), "{cmd}: {e}");
|
||||
}
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc.styles.object.len(), styles);
|
||||
// null: the style stops setting corners, the items keep theirs.
|
||||
s.execute("style.object.edit", &json!({"name": "Framed", "corners": null})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.styles.object_style("Framed").unwrap().corners.is_none());
|
||||
assert_eq!(item(&s, a).corners.corners[0].size, 30.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(item(&s, a).object_style, name);
|
||||
}
|
||||
|
||||
/// The course's chapter 39 (nested object styles): an object style carries a paragraph style,
|
||||
/// with Apply Next Style a chain of them, and text frame options. Applying it styles the frame's
|
||||
/// text; editing it moves the frames that still follow it, not one whose text took another style,
|
||||
/// which Clear Overrides puts back.
|
||||
#[test]
|
||||
fn object_styles_carry_a_paragraph_style_and_text_frame_options() {
|
||||
use designcraft_doc::ItemId;
|
||||
let mut s = session();
|
||||
s.execute("style.paragraph.create", &json!({"name": "Body Text"})).unwrap();
|
||||
s.execute(
|
||||
"style.paragraph.create",
|
||||
&json!({"name": "Drop Cap First Paragraph", "nextStyle": "Body Text", "para": {"dropCapLines": 3, "dropCapChars": 1}}),
|
||||
)
|
||||
.unwrap();
|
||||
let name = "Text Box Drop Cap";
|
||||
s.execute(
|
||||
"style.object.create",
|
||||
&json!({"name": name, "fromSelection": false, "paragraphStyle": "Drop Cap First Paragraph", "applyNextStyle": true, "textFrame": {"inset": [4, 4, 4, 4]}}),
|
||||
)
|
||||
.unwrap();
|
||||
let frame = |s: &mut Session, x: f64, text: &str| {
|
||||
let r = s.execute("frame.create", &json!({"rect": [x, 36, x + 200.0, 300], "content": "text", "text": text, "caret": false})).unwrap();
|
||||
(r["id"].as_u64().unwrap(), r["story"].clone())
|
||||
};
|
||||
let ((a, _), (b, b_story)) = (frame(&mut s, 36.0, "One\nTwo\nThree"), frame(&mut s, 300.0, "Alone"));
|
||||
s.execute("style.object.apply", &json!({"name": name, "ids": [a, b]})).unwrap();
|
||||
let styles_of = |s: &Session, id: u64| {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let sid = d.item(ItemId(id)).unwrap().text_frame().unwrap().story;
|
||||
d.story(sid).unwrap().paras.iter().map(|p| p.style.clone()).collect::<Vec<_>>()
|
||||
};
|
||||
let options = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().text_frame().unwrap().options.clone();
|
||||
assert_eq!(styles_of(&s, a), ["Drop Cap First Paragraph", "Body Text", "Body Text"], "Apply Next Style");
|
||||
assert_eq!(styles_of(&s, b), ["Drop Cap First Paragraph"]);
|
||||
assert_eq!(options(&s, a).inset, [4.0; 4]);
|
||||
|
||||
// b's text takes another paragraph style: its own, kept when the object style changes.
|
||||
s.execute("text.select", &json!({"story": b_story, "anchor": 0, "focus": 5})).unwrap();
|
||||
s.execute("style.paragraph.apply", &json!({"name": "Body Text"})).unwrap();
|
||||
s.execute("style.paragraph.create", &json!({"name": "Box Body Text"})).unwrap();
|
||||
let r = s
|
||||
.execute("style.object.edit", &json!({"name": name, "paragraphStyle": "Box Body Text", "applyNextStyle": false, "textFrame": {"columns": 2}}))
|
||||
.unwrap();
|
||||
assert_eq!((r["updated"].as_u64(), r["restyled"].as_u64()), (Some(2), Some(1)));
|
||||
assert_eq!(styles_of(&s, a), ["Box Body Text"; 3]);
|
||||
assert_eq!(styles_of(&s, b), ["Body Text"], "its own style stays");
|
||||
assert_eq!((options(&s, a).columns, options(&s, a).inset, options(&s, b).columns), (2, [4.0; 4], 2));
|
||||
s.execute("style.object.clearOverrides", &json!({"ids": [b]})).unwrap();
|
||||
assert_eq!(styles_of(&s, b), ["Box Body Text"]);
|
||||
|
||||
// Refused: an unknown paragraph style, no columns, a negative inset, a word for a switch.
|
||||
for bad in [
|
||||
json!({"paragraphStyle": "Nope"}),
|
||||
json!({"paragraphStyle": 3}),
|
||||
json!({"textFrame": {"columns": 0}}),
|
||||
json!({"textFrame": {"inset": [-1, 0, 0, 0]}}),
|
||||
json!({"applyNextStyle": "yes"}),
|
||||
] {
|
||||
let mut p = bad.clone();
|
||||
p["name"] = json!(name);
|
||||
assert!(s.execute("style.object.edit", &p).is_err(), "{bad}");
|
||||
assert!(s.execute("style.object.create", &p).is_err(), "{bad}");
|
||||
}
|
||||
// null: the style no longer sets a paragraph style or frame options; the frames keep theirs.
|
||||
s.execute("style.object.edit", &json!({"name": name, "paragraphStyle": null, "textFrame": null})).unwrap();
|
||||
let os = s.doc().unwrap().doc.styles.object_style(name).cloned().unwrap();
|
||||
assert!(os.paragraph_style.is_none() && os.text_frame.is_none());
|
||||
assert_eq!((styles_of(&s, a), options(&s, a).columns), (vec!["Box Body Text".to_string(); 3], 2));
|
||||
}
|
||||
|
||||
/// An object style's Text Frame General Options set the columns, insets, vertical justification
|
||||
/// and Ignore Text Wrap only: a frame keeps its auto-size and column rule, follows an edit of the
|
||||
/// style while its General options are the style's, and keeps the settings through save and open.
|
||||
/// Applying a style that changes nothing records no undo step, threaded frames share one Next
|
||||
/// Style chain, and keys outside the General options are refused.
|
||||
#[test]
|
||||
fn object_style_text_frame_general_options_leave_the_other_frame_settings() {
|
||||
use designcraft_doc::{AutoSize, ItemId, VerticalJustification};
|
||||
let mut s = session();
|
||||
s.execute("style.paragraph.create", &json!({"name": "Body"})).unwrap();
|
||||
s.execute("style.paragraph.create", &json!({"name": "Lead", "nextStyle": "Body"})).unwrap();
|
||||
let name = "Callout";
|
||||
s.execute(
|
||||
"style.object.create",
|
||||
&json!({"name": name, "fromSelection": false, "paragraphStyle": "Lead", "applyNextStyle": true,
|
||||
"textFrame": {"inset": 6, "verticalJustification": "center"}}),
|
||||
)
|
||||
.unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [36, 36, 156, 200], "content": "text", "text": "One\nTwo", "caret": false})).unwrap();
|
||||
let a = r["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [200, 36, 320, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.textFrameOptions", &json!({"ids": [a], "autoSize": "heightOnly", "columnRule": true})).unwrap();
|
||||
s.execute("style.object.apply", &json!({"name": name, "ids": [a]})).unwrap();
|
||||
let options = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().text_frame().unwrap().options.clone();
|
||||
let o = options(&s, a);
|
||||
assert_eq!((o.inset, o.vertical_justification), ([6.0; 4], VerticalJustification::Center));
|
||||
assert_eq!((o.auto_size, o.column_rule), (AutoSize::HeightOnly, true), "the frame's own auto-size and column rule stay");
|
||||
|
||||
// Applying it again changes nothing and records no undo step.
|
||||
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
let before = undo(&s);
|
||||
s.execute("style.object.apply", &json!({"name": name, "ids": [a]})).unwrap();
|
||||
assert_eq!(undo(&s), before, "no undo step when nothing changes");
|
||||
|
||||
// The frame differs from the style's options only in auto-size, so it follows the edit.
|
||||
let r = s.execute("style.object.edit", &json!({"name": name, "textFrame": {"inset": [1, 2, 3, 4], "ignoreWrap": true}})).unwrap();
|
||||
assert_eq!(r["updated"], 1);
|
||||
let o = options(&s, a);
|
||||
assert_eq!((o.inset, o.ignore_wrap, o.auto_size), ([1.0, 2.0, 3.0, 4.0], true, AutoSize::HeightOnly));
|
||||
// Each option follows on its own: the frame's own inset stays and the new column count arrives.
|
||||
s.execute("object.textFrameOptions", &json!({"ids": [a], "inset": 9})).unwrap();
|
||||
s.execute("style.object.edit", &json!({"name": name, "textFrame": {"columns": 3}})).unwrap();
|
||||
let o = options(&s, a);
|
||||
assert_eq!((o.columns, o.inset), (3, [9.0; 4]));
|
||||
|
||||
// Threaded frames: the story's paragraphs take one Next Style chain across both frames.
|
||||
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
|
||||
s.execute("style.object.apply", &json!({"name": name, "ids": [a, b]})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let sid = d.item(ItemId(a)).unwrap().text_frame().unwrap().story;
|
||||
assert_eq!(d.item(ItemId(b)).unwrap().text_frame().unwrap().story, sid);
|
||||
assert_eq!(d.story(sid).unwrap().paras.iter().map(|p| p.style.as_str()).collect::<Vec<_>>(), ["Lead", "Body"]);
|
||||
|
||||
// Save and open keep the style's General options and Apply Next Style.
|
||||
let bytes = s.execute("file.serialize", &json!({})).unwrap();
|
||||
let mut back = Session::new();
|
||||
back.execute("file.openBytes", &json!({"name": "copy", "base64": bytes["base64"]})).unwrap();
|
||||
let os = back.doc().unwrap().doc.styles.object_style(name).cloned().unwrap();
|
||||
assert!(os.apply_next_style);
|
||||
assert_eq!(os.text_frame.map(|t| (t.inset, t.ignore_wrap)), Some(([1.0, 2.0, 3.0, 4.0], true)));
|
||||
|
||||
// Keys outside the General options, a misspelled key and a word for the inset are refused.
|
||||
for bad in [json!({"autoSize": "heightOnly"}), json!({"insets": 4}), json!({"inset": "wide"}), json!({"verticalJustification": "middle"})] {
|
||||
let p = json!({"name": name, "textFrame": bad});
|
||||
assert!(s.execute("style.object.edit", &p).is_err(), "{bad}");
|
||||
}
|
||||
// A style that already holds 60 columns (as a file can give it) takes other edits, and 60
|
||||
// given again is refused.
|
||||
{
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let st = d.styles_mut().object.iter_mut().find(|o| o.name == name).unwrap();
|
||||
st.text_frame.as_mut().unwrap().columns = 60;
|
||||
}
|
||||
s.execute("style.object.edit", &json!({"name": name, "textFrame": {"inset": 2}})).unwrap();
|
||||
assert!(s.execute("style.object.edit", &json!({"name": name, "textFrame": {"columns": 60}})).is_err());
|
||||
}
|
||||
|
||||
/// A stroke added to a style that set none reaches a drawn shape still carrying its default 1 pt
|
||||
/// stroke and a frame with none, and leaves a shape or text frame with its own stroke.
|
||||
#[test]
|
||||
fn a_stroke_added_to_an_object_style_reaches_default_strokes() {
|
||||
use designcraft_doc::ItemId;
|
||||
let mut s = session();
|
||||
s.execute("style.object.create", &json!({"name": "Boxed", "fromSelection": false})).unwrap();
|
||||
let frame = |s: &mut Session, x: f64, content: &str| {
|
||||
s.execute("frame.create", &json!({"rect": [x, 36, x + 50.0, 86], "content": content, "caret": false})).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let (shape, text, own) = (frame(&mut s, 36.0, "unassigned"), frame(&mut s, 100.0, "text"), frame(&mut s, 200.0, "unassigned"));
|
||||
let stroked_text = frame(&mut s, 300.0, "text");
|
||||
s.execute("object.stroke", &json!({"ids": [own], "weight": 9})).unwrap();
|
||||
s.execute("object.stroke", &json!({"ids": [stroked_text], "weight": 1})).unwrap();
|
||||
s.execute("style.object.apply", &json!({"name": "Boxed", "ids": [shape, text, own, stroked_text]})).unwrap();
|
||||
let r = s.execute("style.object.edit", &json!({"name": "Boxed", "stroke": {"weight": 4}})).unwrap();
|
||||
assert_eq!(r["updated"], 2, "{r}");
|
||||
let weight = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().stroke.weight;
|
||||
assert_eq!((weight(&s, shape), weight(&s, text), weight(&s, own)), (4.0, 4.0, 9.0));
|
||||
assert_eq!(weight(&s, stroked_text), 1.0, "a text frame's own 1 pt stroke stays");
|
||||
}
|
||||
|
||||
/// A paragraph-style edit of an object style reaches each paragraph still in the old style, and a
|
||||
/// paragraph given another style keeps it.
|
||||
#[test]
|
||||
fn an_object_style_edit_restyles_paragraph_by_paragraph() {
|
||||
use designcraft_doc::ItemId;
|
||||
let mut s = session();
|
||||
for n in ["Body", "Lead", "Own"] {
|
||||
s.execute("style.paragraph.create", &json!({"name": n})).unwrap();
|
||||
}
|
||||
s.execute("style.object.create", &json!({"name": "Boxed", "fromSelection": false, "paragraphStyle": "Body"})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [36, 36, 336, 336], "content": "text", "text": "One\nTwo\nThree", "caret": false})).unwrap();
|
||||
let (id, story) = (r["id"].as_u64().unwrap(), r["story"].clone());
|
||||
s.execute("style.object.apply", &json!({"name": "Boxed", "ids": [id]})).unwrap();
|
||||
s.execute("text.select", &json!({"story": story, "anchor": 4, "focus": 7})).unwrap();
|
||||
s.execute("style.paragraph.apply", &json!({"name": "Own"})).unwrap();
|
||||
s.execute("style.object.edit", &json!({"name": "Boxed", "paragraphStyle": "Lead"})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let sid = d.item(ItemId(id)).unwrap().text_frame().unwrap().story;
|
||||
assert_eq!(d.story(sid).unwrap().paras.iter().map(|p| p.style.as_str()).collect::<Vec<_>>(), ["Lead", "Own", "Lead"]);
|
||||
}
|
||||
|
||||
/// A text frame anchored in a story (as IDML import places one) takes a new paragraph style of
|
||||
/// its object style like a frame on the page.
|
||||
#[test]
|
||||
fn an_object_style_edit_restyles_an_anchored_text_frame() {
|
||||
use designcraft_doc::ItemId;
|
||||
let mut s = session();
|
||||
s.execute("style.paragraph.create", &json!({"name": "Body"})).unwrap();
|
||||
s.execute("style.paragraph.create", &json!({"name": "Lead"})).unwrap();
|
||||
s.execute("style.object.create", &json!({"name": "Sidebar", "fromSelection": false, "paragraphStyle": "Body"})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [36, 36, 136, 86], "content": "text", "text": "Inner", "caret": false})).unwrap();
|
||||
let (inner, inner_story) = (ItemId(r["id"].as_u64().unwrap()), designcraft_doc::StoryId(r["story"].as_u64().unwrap()));
|
||||
s.execute("style.object.apply", &json!({"name": "Sidebar", "ids": [inner.0]})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [36, 200, 400, 400], "content": "text", "text": "Before after.", "caret": false})).unwrap();
|
||||
let host = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||||
{
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let it = d.item(inner).unwrap().clone();
|
||||
std::sync::Arc::make_mut(&mut d.spreads[0]).items.retain(|i| i.id != inner);
|
||||
d.story_mut(host).unwrap().insert_object(7, designcraft_doc::AnchoredObject::new(it, Default::default()));
|
||||
}
|
||||
let r = s.execute("style.object.edit", &json!({"name": "Sidebar", "paragraphStyle": "Lead"})).unwrap();
|
||||
assert_eq!(r["restyled"], 1, "{r}");
|
||||
assert_eq!(s.doc().unwrap().doc.story(inner_story).unwrap().paras[0].style, "Lead");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_style_effects_reach_grouped_and_anchored_items() {
|
||||
use designcraft_doc::ItemId;
|
||||
@@ -1721,6 +2031,35 @@ fn a_line_fits_when_its_baseline_does() {
|
||||
assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line");
|
||||
}
|
||||
|
||||
/// Clear Overrides puts a text frame's story back in its object style's paragraph style, as
|
||||
/// applying the style does; and a style's feather choke is kept to 0–100 %, as IDML reads it.
|
||||
#[test]
|
||||
fn object_style_clear_overrides_restores_the_paragraph_style_and_choke_is_clamped() {
|
||||
let mut s = session();
|
||||
s.execute("style.paragraph.create", &json!({"name": "Caption"})).unwrap();
|
||||
s.execute("style.object.create", &json!({"name": "Captioned", "fromSelection": false, "paragraphStyle": "Caption"})).unwrap();
|
||||
let f = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "One\nTwo"})).unwrap();
|
||||
let (id, sid) = (f["id"].as_u64().unwrap(), designcraft_doc::StoryId(f["story"].as_u64().unwrap()));
|
||||
s.execute("style.object.apply", &json!({"name": "Captioned", "ids": [id]})).unwrap();
|
||||
let styles = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().paras.iter().map(|p| p.style.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(styles(&s), ["Caption", "Caption"]);
|
||||
{
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let st = std::sync::Arc::make_mut(doc.stories.get_mut(&sid).unwrap());
|
||||
st.paras[1].style = designcraft_doc::BASIC_PARAGRAPH.into();
|
||||
}
|
||||
let r = s.execute("style.object.clearOverrides", &json!({"ids": [id]})).unwrap();
|
||||
assert_eq!(r["changed"], 1);
|
||||
assert_eq!(styles(&s), ["Caption", "Caption"]);
|
||||
let r = s.execute("style.object.clearOverrides", &json!({"ids": [id]})).unwrap();
|
||||
assert_eq!(r["changed"], 0, "nothing left to clear");
|
||||
|
||||
s.execute("style.object.create", &json!({"name": "Soft", "fromSelection": false, "effects": {"feather": 4, "featherOptions": {"choke": 150}}}))
|
||||
.unwrap();
|
||||
let fx = s.doc().unwrap().doc.styles.object_style("Soft").unwrap().effects.clone().unwrap();
|
||||
assert_eq!(fx.feather_options.choke, 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_tool_previews_cancels_commits_and_undoes_on_the_selected_spread() {
|
||||
use designcraft_tools::{Mods, Overlay, PointerEvent, PointerKind};
|
||||
|
||||
@@ -55,10 +55,53 @@ pub struct CornerOptions {
|
||||
pub corners: [Corner; 4],
|
||||
}
|
||||
|
||||
/// The named corner (0 top left, 1 top right, 2 bottom right, 3 bottom left) each of a path's
|
||||
/// four anchors is, by its place on the path's bounds. A path that isn't one subpath of four
|
||||
/// anchors at four different corners keeps a rectangle's order.
|
||||
pub fn named_slots(path: &PathData) -> [usize; 4] {
|
||||
let rect_order = [0, 1, 2, 3];
|
||||
let Some(sp) = path.subpaths.first() else { return rect_order };
|
||||
let Some(b) = path.bounds() else { return rect_order };
|
||||
if sp.anchors.len() != 4 {
|
||||
return rect_order;
|
||||
}
|
||||
let c = b.center();
|
||||
let mut out = rect_order;
|
||||
let mut seen = [false; 4];
|
||||
for (i, a) in sp.anchors.iter().enumerate() {
|
||||
let k = match (a.p.y < c.y, a.p.x < c.x) {
|
||||
(true, true) => 0,
|
||||
(true, false) => 1,
|
||||
(false, false) => 2,
|
||||
(false, true) => 3,
|
||||
};
|
||||
if std::mem::replace(&mut seen[k], true) {
|
||||
return rect_order;
|
||||
}
|
||||
out[i] = k;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl CornerOptions {
|
||||
pub fn uniform(shape: CornerShape, size: f64) -> Self {
|
||||
Self { corners: [Corner { shape, size }; 4] }
|
||||
}
|
||||
|
||||
/// Named corners (top left, top right, bottom right, bottom left), as an object style holds
|
||||
/// them, in `path`'s order.
|
||||
pub fn named_to_path(&self, path: &PathData) -> Self {
|
||||
Self { corners: named_slots(path).map(|k| self.corners[k]) }
|
||||
}
|
||||
|
||||
/// `path`-order corners as named corners (top left, top right, bottom right, bottom left).
|
||||
pub fn path_to_named(&self, path: &PathData) -> Self {
|
||||
let mut corners = self.corners;
|
||||
for (i, k) in named_slots(path).into_iter().enumerate() {
|
||||
corners[k] = self.corners[i];
|
||||
}
|
||||
Self { corners }
|
||||
}
|
||||
pub fn is_none(&self) -> bool {
|
||||
self.corners.iter().all(Corner::is_none)
|
||||
}
|
||||
@@ -252,6 +295,30 @@ mod tests {
|
||||
use crate::shapes::rectangle;
|
||||
use kurbo::{Rect, Shape};
|
||||
|
||||
/// Named corners go to the anchors at those corners, whatever order the path runs in, and
|
||||
/// come back the same.
|
||||
#[test]
|
||||
fn named_corners_map_to_any_path_order() {
|
||||
use kurbo::Point;
|
||||
let named = CornerOptions {
|
||||
corners: [CornerShape::Rounded, CornerShape::Bevel, CornerShape::Inset, CornerShape::Fancy].map(|shape| Corner { shape, size: 6.0 }),
|
||||
};
|
||||
// Top left, bottom left, bottom right, top right (as InDesign writes a rectangle).
|
||||
let down_first = PathData::single(SubPath::polyline(
|
||||
&[Point::new(0.0, 0.0), Point::new(0.0, 100.0), Point::new(100.0, 100.0), Point::new(100.0, 0.0)],
|
||||
true,
|
||||
));
|
||||
assert_eq!(named_slots(&down_first), [0, 3, 2, 1]);
|
||||
let on_path = named.named_to_path(&down_first);
|
||||
let shapes = on_path.corners.map(|c| c.shape);
|
||||
assert_eq!(shapes, [CornerShape::Rounded, CornerShape::Fancy, CornerShape::Inset, CornerShape::Bevel]);
|
||||
assert_eq!(on_path.path_to_named(&down_first), named);
|
||||
// A rectangle in its own order, and a triangle, keep the order.
|
||||
assert_eq!(named.named_to_path(&rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))), named);
|
||||
let tri = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(5.0, 9.0)], true));
|
||||
assert_eq!(named.named_to_path(&tri), named);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_is_identity() {
|
||||
let r = rectangle(Rect::new(0.0, 0.0, 100.0, 50.0));
|
||||
|
||||
@@ -122,6 +122,8 @@ struct Ex<'a> {
|
||||
embed_limit: usize,
|
||||
/// Images written as links because the embed limit was reached (one line per placement).
|
||||
warnings: Vec<String>,
|
||||
/// Object style name → whether its chain or `[None]` sets corners, found once per style.
|
||||
style_corners: HashMap<String, bool>,
|
||||
}
|
||||
|
||||
/// Image bytes one export embeds, counting each placement (IDML holds a copy per placement): past
|
||||
@@ -145,6 +147,7 @@ impl<'a> Ex<'a> {
|
||||
embedded: 0,
|
||||
embed_limit: MAX_EMBEDDED_BYTES,
|
||||
warnings: Vec::new(),
|
||||
style_corners: HashMap::new(),
|
||||
};
|
||||
ex.assign_swatch_ids();
|
||||
for s in d.stories.values() {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use super::common::{affine_s, bool_s, p, pct, uid, with_props};
|
||||
use super::{
|
||||
Affine, ColumnsKind, Content, El, Ex, HashSet, Item, Node, OBJECT_BUILTINS, PathData, Shape, TextFrameOptions, base64_encode, escape_id, names,
|
||||
num, pt,
|
||||
Affine, ColumnsKind, Content, El, Ex, Item, Node, OBJECT_BUILTINS, PathData, Shape, TextFrameOptions, base64_encode, escape_id, names, num, pt,
|
||||
};
|
||||
|
||||
impl<'a> Ex<'a> {
|
||||
@@ -88,8 +87,10 @@ impl<'a> Ex<'a> {
|
||||
el.set("GradientStrokeAngle", num(a));
|
||||
}
|
||||
self.stroke_attrs(&mut el, &it.stroke);
|
||||
// Corner options: map path-order corners to the named corners by geometry.
|
||||
if !it.corners.is_none() {
|
||||
// Corner options: map path-order corners to the named corners by geometry. An item without
|
||||
// corners whose object style chain sets some writes None, so an import does not give it
|
||||
// the style's corners.
|
||||
if !it.corners.is_none() || self.style_sets_corners(os) {
|
||||
let names_by_index = corner_names(&it.path);
|
||||
for (i, c) in it.corners.corners.iter().enumerate() {
|
||||
let n = names_by_index[i];
|
||||
@@ -154,6 +155,36 @@ impl<'a> Ex<'a> {
|
||||
el
|
||||
}
|
||||
|
||||
/// Whether object style `name`, a style it is based on, or `[None]` (which an import reads
|
||||
/// after every item's style chain) sets corners other than None. A built-in style the
|
||||
/// document lacks counts as its default.
|
||||
fn style_sets_corners(&mut self, name: &str) -> bool {
|
||||
if let Some(&known) = self.style_corners.get(name) {
|
||||
return known;
|
||||
}
|
||||
let defaults = designcraft_doc::Styles::default();
|
||||
let style = |n: &str| self.d.styles.object_style(n).or_else(|| defaults.object_style(n));
|
||||
let rounds = |os: &designcraft_doc::ObjectStyle| os.corners.is_some_and(|c| !c.is_none());
|
||||
let mut seen: Vec<&str> = Vec::new();
|
||||
let mut cur = Some(name);
|
||||
let mut found = false;
|
||||
while let Some(n) = cur.take() {
|
||||
if seen.contains(&n) {
|
||||
break;
|
||||
}
|
||||
let Some(os) = style(n) else { break };
|
||||
if rounds(os) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
seen.push(n);
|
||||
cur = os.based_on.as_deref();
|
||||
}
|
||||
let found = found || style(designcraft_doc::NO_OBJECT_STYLE).is_some_and(rounds);
|
||||
self.style_corners.insert(name.to_string(), found);
|
||||
found
|
||||
}
|
||||
|
||||
fn append_transparency(&self, el: &mut El, it: &Item, os: &str) {
|
||||
// Transparency.
|
||||
// An item whose style sets effects writes the effects it has turned off too, so that on
|
||||
@@ -475,28 +506,7 @@ fn path_geometry(path: &PathData) -> El {
|
||||
|
||||
/// IDML corner names for anchors 0..4 of a path (by position on its bounding box).
|
||||
pub(crate) fn corner_names(path: &PathData) -> [&'static str; 4] {
|
||||
let default = ["TopLeft", "TopRight", "BottomRight", "BottomLeft"];
|
||||
let Some(sp) = path.subpaths.first() else { return default };
|
||||
if sp.anchors.len() != 4 {
|
||||
return default;
|
||||
}
|
||||
let Some(b) = path.bounds() else { return default };
|
||||
let c = b.center();
|
||||
let mut out = default;
|
||||
let mut seen = HashSet::new();
|
||||
for (i, a) in sp.anchors.iter().enumerate() {
|
||||
let n = match (a.p.y < c.y, a.p.x < c.x) {
|
||||
(true, true) => "TopLeft",
|
||||
(true, false) => "TopRight",
|
||||
(false, false) => "BottomRight",
|
||||
(false, true) => "BottomLeft",
|
||||
};
|
||||
if !seen.insert(n) {
|
||||
return default;
|
||||
}
|
||||
out[i] = n;
|
||||
}
|
||||
out
|
||||
designcraft_geom::corners::named_slots(path).map(|k| ["TopLeft", "TopRight", "BottomRight", "BottomLeft"][k])
|
||||
}
|
||||
|
||||
fn format_name(mime: &str) -> &'static str {
|
||||
|
||||
@@ -121,6 +121,13 @@ impl<'a> Ex<'a> {
|
||||
el.set("EnableStroke", bool_s(os.stroke.is_some()));
|
||||
el.set("EnableParagraphStyle", bool_s(os.paragraph_style.is_some()));
|
||||
el.set("EnableTextFrameGeneralOptions", bool_s(os.text_frame.is_some()));
|
||||
if let Some(c) = &os.corners {
|
||||
el.set("EnableStrokeAndCornerOptions", "true");
|
||||
for (n, c) in ["TopLeft", "TopRight", "BottomRight", "BottomLeft"].iter().zip(c.corners) {
|
||||
el.set(&format!("{n}CornerOption"), names::corner_out(c.shape));
|
||||
el.set(&format!("{n}CornerRadius"), num(c.size));
|
||||
}
|
||||
}
|
||||
if let Some(f) = &os.fill {
|
||||
el.set("FillColor", self.sw(&f.swatch));
|
||||
el.set("FillTint", pct(f.tint as f64));
|
||||
@@ -130,6 +137,7 @@ impl<'a> Ex<'a> {
|
||||
}
|
||||
if let Some(ps) = &os.paragraph_style {
|
||||
el.set("AppliedParagraphStyle", names::style_self("ParagraphStyle", PARA_BUILTINS, ps));
|
||||
el.set("ApplyNextParagraphStyle", bool_s(os.apply_next_style));
|
||||
}
|
||||
let mut el = with_props(el, props);
|
||||
if let Some(e) = &os.effects {
|
||||
|
||||
@@ -5,11 +5,25 @@ use super::{Corner, DirectionalFeather, DropShadow, Effects, El, GradientFeather
|
||||
pub(super) const INDESIGN_CORNER_SIZE: f64 = 12.0;
|
||||
|
||||
/// The four corners of a paragraph border or shading (`prefix` `ParagraphBorder` /
|
||||
/// `ParagraphShading`): top left, top right, bottom right, bottom left. Unset when none is given.
|
||||
/// `ParagraphShading`), or an object style (no prefix): top left, top right, bottom right,
|
||||
/// bottom left. Unset when none is given.
|
||||
pub(super) fn corners(e: &El, prefix: &str) -> Option<[Corner; 4]> {
|
||||
corners_in(&[e], prefix)
|
||||
}
|
||||
|
||||
/// [`corners`] over `levels` (an object style, then the styles it is based on): each corner's
|
||||
/// shape and size from the first level that gives it.
|
||||
pub(super) fn corners_in(levels: &[&El], prefix: &str) -> Option<[Corner; 4]> {
|
||||
let keys = ["TopLeft", "TopRight", "BottomRight", "BottomLeft"];
|
||||
let shapes = keys.map(|n| e.prop(&format!("{prefix}{n}CornerOption")));
|
||||
let sizes = keys.map(|n| e.num(&format!("{prefix}{n}CornerRadius")));
|
||||
// An object style (no prefix) may give all four with the legacy `CornerOption` /
|
||||
// `CornerRadius`; at each level a per-corner attribute wins over it, as for page items.
|
||||
let legacy = |e: &El, what: &str| if prefix.is_empty() { e.prop(&format!("Corner{what}")) } else { None };
|
||||
let shapes = keys.map(|n| levels.iter().find_map(|e| e.prop(&format!("{prefix}{n}CornerOption")).or_else(|| legacy(e, "Option"))));
|
||||
let sizes = keys.map(|n| {
|
||||
levels
|
||||
.iter()
|
||||
.find_map(|e| e.num(&format!("{prefix}{n}CornerRadius")).or_else(|| legacy(e, "Radius").and_then(|v| v.trim().parse::<f64>().ok())))
|
||||
});
|
||||
if shapes.iter().all(Option::is_none) && sizes.iter().all(Option::is_none) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use designcraft_geom::corners::CornerOptions;
|
||||
|
||||
use super::object_style::{corners, corners_in};
|
||||
use super::table::uniform_cell_stroke;
|
||||
use super::values::tint;
|
||||
use super::{
|
||||
@@ -170,6 +175,7 @@ impl<'r> Importer<'r> {
|
||||
None => self.styles.table.push(ts),
|
||||
}
|
||||
}
|
||||
let objects_by_self: HashMap<&str, &El> = objects.iter().filter_map(|(_, e)| Some((e.get("Self")?, e))).collect();
|
||||
for (_, e) in &objects {
|
||||
let name = names::style_name_in(OBJECT_BUILTINS, e.get("Name").unwrap_or(""));
|
||||
let based_on = e.prop("BasedOn").map(|b| {
|
||||
@@ -191,13 +197,24 @@ impl<'r> Importer<'r> {
|
||||
let paragraph_style = enabled("EnableParagraphStyle", "AppliedParagraphStyle")
|
||||
.then(|| e.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r)))
|
||||
.flatten();
|
||||
let text_frame = if e.get("EnableTextFrameGeneralOptions") == Some("true") {
|
||||
e.find("TextFramePreference").map(|t| self.frame_options(&[t]))
|
||||
let levels = based_on_levels(e, &objects_by_self);
|
||||
// Text Frame General Options: each attribute from the nearest style whose
|
||||
// `TextFramePreference` sets it, among the styles that don't turn the category off. A
|
||||
// style without the category flag takes it from the style it is based on.
|
||||
let text_frame = if category_on(&levels, "EnableTextFrameGeneralOptions", |_| false) {
|
||||
let prefs: Vec<&El> = levels
|
||||
.iter()
|
||||
.filter(|l| l.get("EnableTextFrameGeneralOptions") != Some("false"))
|
||||
.filter_map(|l| l.find("TextFramePreference"))
|
||||
.collect();
|
||||
(!prefs.is_empty()).then(|| self.frame_options(&prefs))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let effects = self.style_effects(e);
|
||||
let s = ObjectStyle { name: name.clone(), based_on, fill, stroke, paragraph_style, text_frame, effects };
|
||||
let corners = style_corners(&levels);
|
||||
let apply_next_style = paragraph_style.is_some() && e.get("ApplyNextParagraphStyle") == Some("true");
|
||||
let s = ObjectStyle { name: name.clone(), based_on, fill, stroke, paragraph_style, apply_next_style, text_frame, effects, corners };
|
||||
match self.styles.object.iter_mut().find(|o| o.name == name) {
|
||||
Some(slot) => *slot = s,
|
||||
None => self.styles.object.push(s),
|
||||
@@ -395,3 +412,51 @@ impl<'r> Importer<'r> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The corner options (Stroke & Corner Options) of the object style that starts `levels` (the
|
||||
/// style, then the styles it is based on), by name, when it sets them. A style that sets some
|
||||
/// corners, or turns the category on (itself or through the style it is based on), takes the
|
||||
/// others from the styles in `levels` that don't turn the category off.
|
||||
fn style_corners(levels: &[&El]) -> Option<CornerOptions> {
|
||||
if !category_on(levels, "EnableStrokeAndCornerOptions", |l| corners(l, "").is_some()) {
|
||||
return None;
|
||||
}
|
||||
let on: Vec<&El> = levels.iter().copied().filter(|l| l.get("EnableStrokeAndCornerOptions") != Some("false")).collect();
|
||||
corners_in(&on, "").map(|c| CornerOptions { corners: c })
|
||||
}
|
||||
|
||||
/// Whether the style chain `levels` (a style, then the styles it is based on) turns the category
|
||||
/// whose flag is `flag` on. The nearest style that gives the flag decides; a style without it
|
||||
/// turns the category on when `own` finds settings of its own. A `[None]` after the first style
|
||||
/// ends the chain, as a `BasedOn` of `[None]` names no style.
|
||||
fn category_on(levels: &[&El], flag: &str, own: impl Fn(&El) -> bool) -> bool {
|
||||
let none = |l: &&El| l.get("Self") == Some("ObjectStyle/$ID/[None]");
|
||||
for l in levels.iter().take(1).chain(levels.iter().skip(1).take_while(|l| !none(l))) {
|
||||
match l.get(flag) {
|
||||
Some(v) => return v == "true",
|
||||
None if own(l) => return true,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Object style `e`, then the styles it is based on, nearest first (cycle-safe, at most 16). A
|
||||
/// `BasedOn` may name a style by its `Self` id or by its bare name.
|
||||
fn based_on_levels<'e>(e: &'e El, objects_by_self: &HashMap<&str, &'e El>) -> Vec<&'e El> {
|
||||
let id = |b: String| {
|
||||
let b = b.trim();
|
||||
if b.starts_with("ObjectStyle/") { b.to_string() } else { format!("ObjectStyle/{}", names::escape_id(b)) }
|
||||
};
|
||||
let mut levels = vec![e];
|
||||
let mut next = e.prop("BasedOn").map(id);
|
||||
while let Some(b) = next.take()
|
||||
&& levels.len() < 16
|
||||
&& let Some(s) = objects_by_self.get(b.as_str()).copied()
|
||||
&& !levels.iter().any(|l| std::ptr::eq(*l, s))
|
||||
{
|
||||
levels.push(s);
|
||||
next = s.prop("BasedOn").map(id);
|
||||
}
|
||||
levels
|
||||
}
|
||||
@@ -4246,6 +4246,8 @@ fn object_style_effects_round_trip() {
|
||||
};
|
||||
d.styles_mut().object.push(ObjectStyle { name: "Photo".into(), effects: Some(fx.clone()), ..Default::default() });
|
||||
d.styles_mut().object.push(ObjectStyle { name: "Plain".into(), fill: Some(Fill::swatch("Black")), ..Default::default() });
|
||||
let rounded = designcraft_geom::corners::CornerOptions::uniform(designcraft_geom::corners::CornerShape::Rounded, 9.0);
|
||||
d.styles_mut().object.push(ObjectStyle { name: "Rounded".into(), corners: Some(rounded), ..Default::default() });
|
||||
let lid = d.default_layer();
|
||||
let id = designcraft_doc::ItemId(d.alloc());
|
||||
let mut it = designcraft_doc::Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(40.0, 40.0, 140.0, 140.0)));
|
||||
@@ -4276,6 +4278,9 @@ fn object_style_effects_round_trip() {
|
||||
assert!((ds.distance - 5.0).abs() < 1e-6 && (ds.angle - DropShadow::default().angle).abs() < 1e-6, "{ds:?}");
|
||||
assert_eq!(got.feather, 3.0);
|
||||
assert!(style("Plain").effects.is_none());
|
||||
// Corner options (Stroke & Corner Options).
|
||||
assert_eq!(style("Rounded").corners, Some(rounded));
|
||||
assert_eq!(style("Plain").corners, None);
|
||||
// The frame's effects are the style's, so it follows later edits of the style.
|
||||
let frames: Vec<_> = back.spreads[0].items.iter().filter(|i| i.object_style == "Photo").collect();
|
||||
assert_eq!(frames[0].effects, got);
|
||||
|
||||
@@ -149,6 +149,71 @@ fn item_overrides_and_disabled_categories_turn_style_effects_off() {
|
||||
assert!((ds.opacity - 0.75).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// An object style's paragraph style and Apply Next Style (`ApplyNextParagraphStyle`) round-trip.
|
||||
#[test]
|
||||
fn object_style_apply_next_style_round_trips() {
|
||||
let mut d = small_doc();
|
||||
let styles = std::sync::Arc::make_mut(&mut d.styles);
|
||||
styles.paragraph.push(designcraft_doc::ParagraphStyle {
|
||||
name: "Opener".into(),
|
||||
based_on: None,
|
||||
next_style: Some(designcraft_doc::BASIC_PARAGRAPH.into()),
|
||||
para: Default::default(),
|
||||
chars: Default::default(),
|
||||
shortcut: String::new(),
|
||||
});
|
||||
for (name, next) in [("Chained", true), ("Single", false)] {
|
||||
styles.object.push(designcraft_doc::ObjectStyle {
|
||||
name: name.into(),
|
||||
paragraph_style: Some("Opener".into()),
|
||||
apply_next_style: next,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let back = import_idml(&export_idml(&d)).unwrap();
|
||||
for (name, next) in [("Chained", true), ("Single", false)] {
|
||||
let os = back.styles.object_style(name).unwrap();
|
||||
assert_eq!((os.paragraph_style.as_deref(), os.apply_next_style), (Some("Opener"), next), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A style's Text Frame General Options take each setting its own `TextFramePreference` leaves out
|
||||
/// from the style it is based on (named by its id or bare name, and only when that style doesn't
|
||||
/// turn the category off), and export and import keep the merged options.
|
||||
#[test]
|
||||
fn object_style_text_frame_options_inherit_from_the_based_on_style() {
|
||||
let styles = r#"<ObjectStyle Self="ObjectStyle/Box" Name="Box" EnableTextFrameGeneralOptions="true">
|
||||
<Properties><BasedOn type="string">$ID/[None]</BasedOn></Properties>
|
||||
<TextFramePreference TextColumnCount="2" VerticalJustification="CenterAlign"/>
|
||||
</ObjectStyle>
|
||||
<ObjectStyle Self="ObjectStyle/Inset" Name="Inset" EnableTextFrameGeneralOptions="true">
|
||||
<Properties><BasedOn type="object">ObjectStyle/Box</BasedOn></Properties>
|
||||
<TextFramePreference InsetSpacing="9"/>
|
||||
</ObjectStyle>
|
||||
<ObjectStyle Self="ObjectStyle/Bare" Name="Bare" EnableTextFrameGeneralOptions="true">
|
||||
<Properties><BasedOn type="string">Box</BasedOn></Properties>
|
||||
<TextFramePreference InsetSpacing="3"/>
|
||||
</ObjectStyle>
|
||||
<ObjectStyle Self="ObjectStyle/Off" Name="Off" EnableTextFrameGeneralOptions="false">
|
||||
<Properties><BasedOn type="string">$ID/[None]</BasedOn></Properties>
|
||||
<TextFramePreference TextColumnCount="5"/>
|
||||
</ObjectStyle>
|
||||
<ObjectStyle Self="ObjectStyle/OnOff" Name="OnOff" EnableTextFrameGeneralOptions="true">
|
||||
<Properties><BasedOn type="object">ObjectStyle/Off</BasedOn></Properties>
|
||||
<TextFramePreference InsetSpacing="1"/>
|
||||
</ObjectStyle>"#;
|
||||
let d = fixture(styles, &[]);
|
||||
let tf = d.styles.object_style("Inset").and_then(|s| s.text_frame.clone()).expect("Inset sets text frame options");
|
||||
assert_eq!((tf.inset, tf.columns, tf.vertical_justification), ([9.0; 4], 2, designcraft_doc::VerticalJustification::Center));
|
||||
let bare = d.styles.object_style("Bare").and_then(|s| s.text_frame.clone()).expect("Bare sets text frame options");
|
||||
assert_eq!((bare.inset, bare.columns), ([3.0; 4], 2), "a BasedOn given as a bare name");
|
||||
let on_off = d.styles.object_style("OnOff").and_then(|s| s.text_frame.clone()).expect("OnOff sets text frame options");
|
||||
assert_eq!((on_off.inset, on_off.columns), ([1.0; 4], 1), "a based-on style with the category off gives nothing");
|
||||
let back = import_idml(&export_idml(&d)).unwrap();
|
||||
let again = back.styles.object_style("Inset").and_then(|s| s.text_frame.clone()).expect("after a round trip");
|
||||
assert!(again.general_eq(&tf), "{again:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_frames_keep_their_own_values_over_their_object_style() {
|
||||
let mut d = small_doc();
|
||||
@@ -167,6 +232,43 @@ fn exported_frames_keep_their_own_values_over_their_object_style() {
|
||||
assert_eq!(got.options, want);
|
||||
}
|
||||
|
||||
/// Items without corners keep none after export and import, though their object style, the style
|
||||
/// it is based on, or `[None]` (read after every style chain) sets rounded corners.
|
||||
#[test]
|
||||
fn exported_frames_keep_no_corners_over_their_object_style() {
|
||||
let mut d = small_doc();
|
||||
let lid = d.default_layer();
|
||||
let id = designcraft_doc::ItemId(d.alloc());
|
||||
let rect = designcraft_doc::Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(40.0, 420.0, 200.0, 500.0)));
|
||||
d.insert_item(SpreadRef::Doc(0), rect, None).unwrap();
|
||||
let rounded = Some(designcraft_geom::corners::CornerOptions {
|
||||
corners: [designcraft_geom::corners::Corner { shape: CornerShape::Rounded, size: 12.0 }; 4],
|
||||
});
|
||||
let styles = std::sync::Arc::make_mut(&mut d.styles);
|
||||
styles.object.push(designcraft_doc::ObjectStyle { name: "Round".into(), corners: rounded, ..Default::default() });
|
||||
styles.object.push(designcraft_doc::ObjectStyle { name: "Child".into(), based_on: Some("Round".into()), ..Default::default() });
|
||||
match styles.object.iter_mut().find(|o| o.name == designcraft_doc::NO_OBJECT_STYLE) {
|
||||
Some(none) => none.corners = rounded,
|
||||
None => {
|
||||
styles.object.push(designcraft_doc::ObjectStyle { name: designcraft_doc::NO_OBJECT_STYLE.into(), corners: rounded, ..Default::default() })
|
||||
}
|
||||
}
|
||||
styles.object.push(designcraft_doc::ObjectStyle { name: "Plain".into(), ..Default::default() });
|
||||
let names = ["Round", "Child", designcraft_doc::NO_OBJECT_STYLE, "Plain"];
|
||||
let ids: Vec<_> = d.spreads.iter().flat_map(|s| s.items.iter().map(|i| i.id)).take(4).collect();
|
||||
assert_eq!(ids.len(), 4);
|
||||
for (id, style) in ids.iter().zip(names) {
|
||||
let it = d.item_mut(*id).unwrap();
|
||||
it.object_style = style.into();
|
||||
assert!(it.corners.is_none());
|
||||
}
|
||||
let back = import_idml(&export_idml(&d)).unwrap();
|
||||
for style in names {
|
||||
let it = back.spreads.iter().flat_map(|s| s.items.iter()).find(|i| i.object_style == style).unwrap();
|
||||
assert!(it.corners.is_none(), "{style}: {:?}", it.corners);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_wrap_side_and_contour_come_from_the_style_for_frames_and_placed_graphics() {
|
||||
let styles = r#"<ObjectStyle Self="ObjectStyle/Contour" Name="Contour">
|
||||
@@ -227,3 +329,86 @@ fn feathers_come_from_the_object_style_unless_its_category_turns_them_off() {
|
||||
assert!(fx[2].gradient_feather.on);
|
||||
assert!(!fx[2].directional_feather.on, "the item's own setting wins");
|
||||
}
|
||||
|
||||
/// A style that sets one corner takes the others from the style it is based on, so applying it
|
||||
/// keeps the based-on style's rounded corners; and a style's named corners go to the anchors at
|
||||
/// those corners of a frame whose path runs top left, bottom left, bottom right, top right.
|
||||
#[test]
|
||||
fn a_style_setting_one_corner_inherits_the_others_and_names_them() {
|
||||
let child = r#"<ObjectStyle Self="ObjectStyle/Child" Name="Child" TopRightCornerOption="BevelCorner" TopRightCornerRadius="4">
|
||||
<Properties><BasedOn type="object">ObjectStyle/Round</BasedOn></Properties>
|
||||
</ObjectStyle>"#;
|
||||
let d = fixture(&format!("{ROUND}{child}"), &[(r#"AppliedObjectStyle="ObjectStyle/Child""#, "")]);
|
||||
let style = d.styles.object.iter().find(|s| s.name == "Child").unwrap();
|
||||
let c = style.corners.expect("the child sets corners").corners;
|
||||
assert_eq!(c.map(|c| c.shape), [CornerShape::Rounded, CornerShape::Bevel, CornerShape::Rounded, CornerShape::Rounded], "{c:?}");
|
||||
assert_eq!(c.map(|c| c.size), [17.0, 4.0, 17.0, 17.0]);
|
||||
// Applied to the fixture's frame (anchors top left, bottom left, bottom right, top right):
|
||||
// the bevel goes to the top right anchor, the last.
|
||||
let mut it = (*d.spreads[0].items[0]).clone();
|
||||
it.corners = Default::default();
|
||||
style.apply_to(&mut it);
|
||||
assert_eq!(it.corners.corners.map(|c| c.shape), [CornerShape::Rounded, CornerShape::Rounded, CornerShape::Rounded, CornerShape::Bevel]);
|
||||
}
|
||||
|
||||
/// A style that turns Stroke & Corner Options on without corner attributes of its own takes all
|
||||
/// four corners from the style it is based on.
|
||||
#[test]
|
||||
fn a_style_turning_corners_on_inherits_all_four() {
|
||||
let child = r#"<ObjectStyle Self="ObjectStyle/Plain" Name="Plain" EnableStrokeAndCornerOptions="true">
|
||||
<Properties><BasedOn type="object">ObjectStyle/Round</BasedOn></Properties>
|
||||
</ObjectStyle>"#;
|
||||
let d = fixture(&format!("{ROUND}{child}"), &[]);
|
||||
let c = d.styles.object.iter().find(|s| s.name == "Plain").and_then(|s| s.corners).expect("inherited corners").corners;
|
||||
assert_eq!((c.map(|c| c.shape), c.map(|c| c.size)), ([CornerShape::Rounded; 4], [17.0; 4]));
|
||||
}
|
||||
|
||||
/// A style with no Stroke & Corner Options or Text Frame General Options flag and no settings of
|
||||
/// its own takes each category from the style it is based on: on, off, or (for a style based on
|
||||
/// `[None]`) not set.
|
||||
#[test]
|
||||
fn a_style_without_category_flags_takes_them_from_its_based_on_style() {
|
||||
let styles = r#"<ObjectStyle Self="ObjectStyle/Framed" Name="Framed" EnableTextFrameGeneralOptions="true">
|
||||
<Properties><BasedOn type="string">$ID/[None]</BasedOn></Properties>
|
||||
<TextFramePreference TextColumnCount="3"/>
|
||||
</ObjectStyle>
|
||||
<ObjectStyle Self="ObjectStyle/Off" Name="Off" EnableStrokeAndCornerOptions="false" EnableTextFrameGeneralOptions="false"
|
||||
TopLeftCornerOption="RoundedCorner">
|
||||
<Properties><BasedOn type="object">ObjectStyle/Round</BasedOn></Properties>
|
||||
<TextFramePreference TextColumnCount="4"/>
|
||||
</ObjectStyle>
|
||||
<ObjectStyle Self="ObjectStyle/RoundKid" Name="RoundKid">
|
||||
<Properties><BasedOn type="object">ObjectStyle/Round</BasedOn></Properties>
|
||||
</ObjectStyle>
|
||||
<ObjectStyle Self="ObjectStyle/FramedKid" Name="FramedKid">
|
||||
<Properties><BasedOn type="object">ObjectStyle/Framed</BasedOn></Properties>
|
||||
</ObjectStyle>
|
||||
<ObjectStyle Self="ObjectStyle/OffKid" Name="OffKid">
|
||||
<Properties><BasedOn type="object">ObjectStyle/Off</BasedOn></Properties>
|
||||
</ObjectStyle>
|
||||
<ObjectStyle Self="ObjectStyle/Bare" Name="Bare">
|
||||
<Properties><BasedOn type="string">$ID/[None]</BasedOn></Properties>
|
||||
</ObjectStyle>"#;
|
||||
let d = fixture(&format!("{ROUND}{styles}"), &[]);
|
||||
let style = |n: &str| d.styles.object_style(n).cloned().unwrap();
|
||||
let c = style("RoundKid").corners.expect("RoundKid inherits Round's corners").corners;
|
||||
assert_eq!((c.map(|c| c.shape), c.map(|c| c.size)), ([CornerShape::Rounded; 4], [17.0; 4]));
|
||||
assert_eq!(style("FramedKid").text_frame.map(|t| t.columns), Some(3), "FramedKid inherits Framed's text frame options");
|
||||
for n in ["OffKid", "Bare"] {
|
||||
assert!(style(n).corners.is_none() && style(n).text_frame.is_none(), "{n}: {:?}", style(n));
|
||||
}
|
||||
assert!(style(designcraft_doc::NO_OBJECT_STYLE).corners.is_some(), "[None] keeps the corners it sets itself");
|
||||
}
|
||||
|
||||
/// An object style giving its corners only with the legacy all-corners `CornerOption` and
|
||||
/// `CornerRadius` keeps them, so applying it to another frame rounds it.
|
||||
#[test]
|
||||
fn a_style_with_legacy_all_corner_attributes_keeps_its_corners() {
|
||||
let legacy = r#"<ObjectStyle Self="ObjectStyle/Legacy" Name="Legacy" CornerOption="RoundedCorner" CornerRadius="9">
|
||||
<Properties><BasedOn type="string">$ID/[None]</BasedOn></Properties>
|
||||
</ObjectStyle>"#;
|
||||
let d = fixture(legacy, &[]);
|
||||
let style = d.styles.object.iter().find(|s| s.name == "Legacy").unwrap();
|
||||
let c = style.corners.expect("the legacy corners").corners;
|
||||
assert!(c.iter().all(|c| c.shape == CornerShape::Rounded && c.size == 9.0), "{c:?}");
|
||||
}
|
||||
@@ -104,7 +104,7 @@ impl Dialog {
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
fn m(&self, k: &str) -> Option<f64> {
|
||||
pub(crate) fn m(&self, k: &str) -> Option<f64> {
|
||||
self.m_in(k, self.unit)
|
||||
}
|
||||
/// A measure where a bare number is in `unit`.
|
||||
@@ -1054,9 +1054,44 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) {
|
||||
}
|
||||
let o = selected_frame_options(app).unwrap_or_default();
|
||||
let units = app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas);
|
||||
let mut shown = text_frame_fields(&o, units, selected_frame_width(app), &document_grid(app));
|
||||
// A non-rectangular frame shows one Inset (its top inset, the one kept from the outline).
|
||||
let shaped = selected_frame_is_shaped(app);
|
||||
if shaped && let Value::Object(m) = &mut shown {
|
||||
m.insert("inset".into(), json!(format_measure(o.inset[0], units)));
|
||||
}
|
||||
d.fields.insert("_shaped".into(), json!(shaped));
|
||||
if let Value::Object(m) = &shown {
|
||||
for (k, v) in m {
|
||||
d.fields.entry(k.clone()).or_insert_with(|| v.clone());
|
||||
}
|
||||
}
|
||||
// Linked only when no selected frame has uneven sides, so a typed side cannot overwrite them.
|
||||
d.fields.entry("insetLinked".to_string()).or_insert_with(|| json!(selected_frames_have_even_insets(app)));
|
||||
// The frame's width: Fixed Number shows each column's width from it as the fields change.
|
||||
if let Some(w) = selected_frame_width(app) {
|
||||
d.fields.insert("_frameWidth".into(), json!(w));
|
||||
}
|
||||
d.fields.insert("_shown".into(), shown);
|
||||
}
|
||||
|
||||
/// The active document's baseline grid (the default one without a document).
|
||||
pub(crate) fn document_grid(app: &DesignApp) -> designcraft_doc::BaselineGrid {
|
||||
app.session.active().map(|s| s.doc.settings.baseline_grid.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Text frame options `o` as the Text Frame Options dialog's fields, measures in `units`; with the
|
||||
/// frame's width, a Fixed Number column shows the width it gets. Without a custom baseline grid of
|
||||
/// its own, the frame shows `doc_grid`'s start and increment.
|
||||
pub(crate) fn text_frame_fields(
|
||||
o: &designcraft_doc::TextFrameOptions,
|
||||
units: Unit,
|
||||
frame_width: Option<f64>,
|
||||
doc_grid: &designcraft_doc::BaselineGrid,
|
||||
) -> Value {
|
||||
let fm = |v: f64| json!(format_measure(v, units));
|
||||
// Fixed Number: the width each column gets in the frame (shown, as in InDesign).
|
||||
let inner = selected_frame_width(app).map(|w| w - o.inset[1] - o.inset[3]).unwrap_or(0.0);
|
||||
let inner = frame_width.map(|w| w - o.inset[1] - o.inset[3]).unwrap_or(0.0);
|
||||
let n = o.columns.max(1) as f64;
|
||||
let width = match o.columns_kind {
|
||||
designcraft_doc::ColumnsKind::FixedWidth if o.column_width > 0.0 => o.column_width,
|
||||
@@ -1065,14 +1100,14 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) {
|
||||
let kind = |v: Value| v.as_str().unwrap_or_default().to_string();
|
||||
// A frame without a custom baseline grid shows the document grid's start and increment.
|
||||
let grid = o.baseline_grid.unwrap_or_else(|| {
|
||||
let g = app.session.active().map(|s| s.doc.settings.baseline_grid.clone()).unwrap_or_default();
|
||||
let g = doc_grid;
|
||||
let relative_to = match g.relative_to {
|
||||
designcraft_doc::GridRelative::TopOfPage => designcraft_doc::FrameGridRelative::TopOfPage,
|
||||
designcraft_doc::GridRelative::TopMargin => designcraft_doc::FrameGridRelative::TopMargin,
|
||||
};
|
||||
designcraft_doc::FrameBaselineGrid { start: g.start, increment: g.increment, relative_to }
|
||||
});
|
||||
let mut shown = json!({
|
||||
json!({
|
||||
"columns": o.columns, "gutter": fm(o.gutter),
|
||||
"columnsKind": kind(serde_json::to_value(o.columns_kind).unwrap_or_default()), "columnWidth": fm(width),
|
||||
"balanceColumns": o.balance_columns, "ignoreWrap": o.ignore_wrap,
|
||||
@@ -1090,25 +1125,7 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) {
|
||||
"noLineBreaks": o.auto_size_no_line_breaks,
|
||||
"customGrid": o.baseline_grid.is_some(), "gridStart": fm(grid.start), "gridIncrement": fm(grid.increment),
|
||||
"gridRelativeTo": grid.relative_to.name(),
|
||||
});
|
||||
// A non-rectangular frame shows one Inset (its top inset, the one kept from the outline).
|
||||
let shaped = selected_frame_is_shaped(app);
|
||||
if shaped && let Value::Object(m) = &mut shown {
|
||||
m.insert("inset".into(), fm(o.inset[0]));
|
||||
}
|
||||
d.fields.insert("_shaped".into(), json!(shaped));
|
||||
if let Value::Object(m) = &shown {
|
||||
for (k, v) in m {
|
||||
d.fields.entry(k.clone()).or_insert_with(|| v.clone());
|
||||
}
|
||||
}
|
||||
// Linked only when no selected frame has uneven sides, so a typed side cannot overwrite them.
|
||||
d.fields.entry("insetLinked".to_string()).or_insert_with(|| json!(selected_frames_have_even_insets(app)));
|
||||
// The frame's width: Fixed Number shows each column's width from it as the fields change.
|
||||
if let Some(w) = selected_frame_width(app) {
|
||||
d.fields.insert("_frameWidth".into(), json!(w));
|
||||
}
|
||||
d.fields.insert("_shown".into(), shown);
|
||||
})
|
||||
}
|
||||
|
||||
/// A 0..1 tint as a percentage ("40", "12.5").
|
||||
@@ -1270,7 +1287,7 @@ fn text_frame_preview(app: &mut DesignApp, d: &mut Dialog) {
|
||||
/// maximum, Balance Columns), Inset Spacing (top and bottom, the link, left and right; one Inset
|
||||
/// for a non-rectangular frame), Vertical Justification (align, paragraph spacing limit) and
|
||||
/// Ignore Text Wrap.
|
||||
fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
pub(crate) fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let tr = |s: &'static str| crate::i18n::tr(lang, s);
|
||||
let (cap, field, cap2) = (64.0, 110.0, 70.0);
|
||||
framed(ui, None, |ui| {
|
||||
@@ -3122,6 +3139,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
crate::export_batch_dialog::ID => crate::i18n::tr(&app.ui.language, "Export Layouts and Versions"),
|
||||
crate::package_dialog::ID => crate::i18n::tr(&app.ui.language, "Package"),
|
||||
crate::package_dialog::INSTRUCTIONS => crate::i18n::tr(&app.ui.language, "Printing Instructions"),
|
||||
crate::object_style_dialog::ID => crate::i18n::tr(&app.ui.language, crate::object_style_dialog::title(&d)),
|
||||
id => match id.strip_prefix("cmd:").and_then(designcraft_engine::find_command) {
|
||||
Some(c) => crate::i18n::tr(&app.ui.language, c.label.trim_end_matches('…')),
|
||||
None => crate::i18n::tr(&app.ui.language, "Dialog"),
|
||||
@@ -3147,6 +3165,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
"colorPicker" => 580.0,
|
||||
crate::package_dialog::ID => 720.0,
|
||||
crate::package_dialog::INSTRUCTIONS => 520.0,
|
||||
crate::object_style_dialog::ID => 760.0,
|
||||
_ => 640.0,
|
||||
};
|
||||
let width = preferred_width.min(available.x);
|
||||
@@ -3493,6 +3512,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
crate::export_batch_dialog::ID => crate::export_batch_dialog::show(app, ui, &mut d),
|
||||
crate::package_dialog::ID => crate::package_dialog::show(app, ui, &mut d),
|
||||
crate::package_dialog::INSTRUCTIONS => crate::package_dialog::show_instructions(app, ui, &mut d),
|
||||
crate::object_style_dialog::ID => crate::object_style_dialog::show(app, ui, &mut d),
|
||||
"preferences" => preferences(app, ui, &mut d, body_height),
|
||||
"print" => print_dialog(app, ui, &mut d),
|
||||
"pdfImport" => {
|
||||
@@ -5277,6 +5297,16 @@ fn confirm_part6(app: &mut DesignApp, d: Dialog) -> Result<Value, String> {
|
||||
}
|
||||
r
|
||||
}
|
||||
crate::object_style_dialog::ID => {
|
||||
let r = crate::object_style_dialog::confirm(app, &d);
|
||||
if let Err(e) = &r {
|
||||
// Keep the dialog and its edits open with the reason (an empty or taken name).
|
||||
let mut d = d.clone();
|
||||
d.fields.insert("status".into(), json!(e));
|
||||
app.ui.dialog = Some(d);
|
||||
}
|
||||
r
|
||||
}
|
||||
"findChange" => {
|
||||
let result = if d.b("objectMode") { Ok(Value::Null) } else { app.run("find.change", find_change_params(&d)) };
|
||||
// Confirmation consumes retained bounds before closing, even on an error.
|
||||
|
||||
@@ -19,6 +19,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
|
||||
("effects", "Effects", "panel-effects"),
|
||||
("paragraphStyles", "Paragraph Styles", "panel-pstyles"),
|
||||
("characterStyles", "Character Styles", "panel-cstyles"),
|
||||
("objectStyles", "Object Styles", "frame-dashed"),
|
||||
("character", "Character", "panel-character"),
|
||||
("typography", "Typography", "panel-typography"),
|
||||
("glyphs", "Glyphs", "panel-character"),
|
||||
@@ -84,6 +85,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) {
|
||||
"swatches" => panels::swatches::show(app, ui),
|
||||
"paragraphStyles" => panels::styles::paragraph(app, ui),
|
||||
"characterStyles" => panels::styles::character(app, ui),
|
||||
"objectStyles" => panels::styles::object(app, ui),
|
||||
"stroke" => panels::properties::stroke_panel(app, ui),
|
||||
"character" => panels::properties::character_panel(app, ui),
|
||||
"typography" => panels::typography::show(app, ui),
|
||||
|
||||
@@ -65,7 +65,7 @@ pub fn open(app: &mut DesignApp, page: &str) {
|
||||
}
|
||||
|
||||
/// Every effect's settings, those still at their defaults included (the document leaves them out).
|
||||
fn effects_json(e: &designcraft_doc::Effects) -> Value {
|
||||
pub(crate) fn effects_json(e: &designcraft_doc::Effects) -> Value {
|
||||
use designcraft_doc::{Bevel, DirectionalFeather, DropShadow, FeatherOptions, GradientFeather, InnerGlow, InnerShadow, OuterGlow, Satin};
|
||||
let mut fx = serde_json::to_value(e).unwrap_or_else(|_| json!({}));
|
||||
let defaults = [
|
||||
@@ -90,7 +90,7 @@ fn effects_json(e: &designcraft_doc::Effects) -> Value {
|
||||
}
|
||||
|
||||
/// Turn the effect of `page` on, as choosing it does (Basic Feather: its default width).
|
||||
fn turn_on(fx: &mut Value, page: &str) {
|
||||
pub(crate) fn turn_on(fx: &mut Value, page: &str) {
|
||||
match page {
|
||||
"transparency" => {}
|
||||
"feather" => {
|
||||
@@ -106,7 +106,7 @@ fn turn_on(fx: &mut Value, page: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_on(fx: &Value, page: &str) -> bool {
|
||||
pub(crate) fn is_on(fx: &Value, page: &str) -> bool {
|
||||
match page {
|
||||
"transparency" => true,
|
||||
"feather" => fx["feather"].as_f64().unwrap_or(0.0) > 0.0,
|
||||
@@ -374,7 +374,7 @@ fn offset(ui: &mut egui::Ui, d: &mut Dialog, key: &str, unit: Unit, global: f64,
|
||||
}
|
||||
|
||||
/// The settings of `page`.
|
||||
fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) {
|
||||
pub(crate) fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) {
|
||||
let lang = app.ui.language.clone();
|
||||
let tr = |s: &str| crate::i18n::tr(&lang, s).to_string();
|
||||
let unit = app.session.active().map_or(Unit::Points, |st| st.doc.settings.horizontal_units);
|
||||
|
||||
@@ -33,6 +33,19 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Cut", "قص"),
|
||||
("Copy", "نسخ"),
|
||||
("Paste", "لصق"),
|
||||
("Apply Next Style", "تطبيق النمط التالي"),
|
||||
("Text Frame General Options", "خيارات عامة لإطار النص"),
|
||||
("New Object Style", "نمط كائن جديد"),
|
||||
("Object Style Options", "خيارات نمط الكائن"),
|
||||
("Object Style Options…", "خيارات نمط الكائن…"),
|
||||
("Basic Attributes", "السمات الأساسية"),
|
||||
("Stroke & Corner Options", "خيارات الحد والزوايا"),
|
||||
("Effects for:", "التأثيرات لـ:"),
|
||||
("Create New Style", "إنشاء نمط جديد"),
|
||||
("Clear Overrides", "مسح التجاوزات"),
|
||||
("Shape:", "الشكل:"),
|
||||
("(no attributes)", "(لا سمات)"),
|
||||
("Enter a style name.", "أدخل اسم نمط."),
|
||||
("Keep In Frame", "الإبقاء داخل الإطار"),
|
||||
("New Paragraph Style", "نمط فقرة جديد"),
|
||||
("New Character Style", "نمط حرف جديد"),
|
||||
|
||||
@@ -34,6 +34,19 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Cut", "Vyjmout"),
|
||||
("Copy", "Kopírovat"),
|
||||
("Paste", "Vložit"),
|
||||
("Apply Next Style", "Použít další styl"),
|
||||
("Text Frame General Options", "Obecné volby textového rámečku"),
|
||||
("New Object Style", "Nový styl objektu"),
|
||||
("Object Style Options", "Možnosti stylu objektu"),
|
||||
("Object Style Options…", "Možnosti stylu objektu…"),
|
||||
("Basic Attributes", "Základní atributy"),
|
||||
("Stroke & Corner Options", "Možnosti tahu a rohů"),
|
||||
("Effects for:", "Efekty pro:"),
|
||||
("Create New Style", "Vytvořit nový styl"),
|
||||
("Clear Overrides", "Zrušit přepsání"),
|
||||
("Shape:", "Tvar:"),
|
||||
("(no attributes)", "(žádné atributy)"),
|
||||
("Enter a style name.", "Zadejte název stylu."),
|
||||
("Keep In Frame", "Zachovat v rámečku"),
|
||||
("Gap Tint:", "Odstín mezery:"),
|
||||
("New Paragraph Style", "Nový styl odstavce"),
|
||||
|
||||
@@ -33,6 +33,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Cut", "Ausschneiden"),
|
||||
("Copy", "Kopieren"),
|
||||
("Paste", "Einfügen"),
|
||||
("Apply Next Style", "Nächstes Format anwenden"),
|
||||
("Text Frame General Options", "Allgemeine Textrahmenoptionen"),
|
||||
("Stroke Type", "Konturtyp"),
|
||||
("Corner Shape", "Eckenform"),
|
||||
("Corner Size and Shape:", "Eckengröße und -form:"),
|
||||
|
||||
@@ -245,7 +245,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Numbering & Section Options", "Options de numérotation et de section"),
|
||||
("OK", "OK"),
|
||||
("Object Style", "Style d'objet"),
|
||||
("Object Style Options…", "Options de style d'objet…"),
|
||||
("Object Styles", "Styles d'objet"),
|
||||
("Object/Anchored Object", "Objet/Objet ancré"),
|
||||
("Object/Arrange", "Objet/Disposition"),
|
||||
|
||||
@@ -1983,6 +1983,19 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Measure Tool", "Mérőeszköz"),
|
||||
("Hand Tool", "Kéz eszköz"),
|
||||
("Zoom Tool", "Nagyító eszköz"),
|
||||
("Apply Next Style", "Következő stílus alkalmazása"),
|
||||
("Text Frame General Options", "Szövegkeret általános beállításai"),
|
||||
("New Object Style", "Új objektumstílus"),
|
||||
("Object Style Options", "Objektumstílus beállításai"),
|
||||
("Object Style Options…", "Objektumstílus beállításai…"),
|
||||
("Basic Attributes", "Alapattribútumok"),
|
||||
("Stroke & Corner Options", "Körvonal- és sarokbeállítások"),
|
||||
("Effects for:", "Hatások ehhez:"),
|
||||
("Create New Style", "Új stílus létrehozása"),
|
||||
("Clear Overrides", "Felülírások törlése"),
|
||||
("Shape:", "Alakzat:"),
|
||||
("(no attributes)", "(nincsenek attribútumok)"),
|
||||
("Enter a style name.", "Adjon meg egy stílusnevet."),
|
||||
];
|
||||
|
||||
pub(super) const CONTEXT: &[(&str, &str, &str)] =
|
||||
|
||||
@@ -34,6 +34,20 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Cut", "Taglia"),
|
||||
("Copy", "Copia"),
|
||||
("Paste", "Incolla"),
|
||||
("Apply Next Style", "Applica stile successivo"),
|
||||
("Text Frame General Options", "Opzioni generali cornice di testo"),
|
||||
("New Object Style", "Nuovo stile oggetto"),
|
||||
("Object Style Options", "Opzioni stile oggetto"),
|
||||
("Object Style Options…", "Opzioni stile oggetto…"),
|
||||
("Basic Attributes", "Attributi di base"),
|
||||
("Stroke & Corner Options", "Opzioni traccia e angoli"),
|
||||
("Effects for:", "Effetti per:"),
|
||||
("Create New Style", "Crea nuovo stile"),
|
||||
("Clear Overrides", "Cancella sostituzioni"),
|
||||
("Shape:", "Forma:"),
|
||||
("(no attributes)", "(nessun attributo)"),
|
||||
("Enter a style name.", "Inserisci un nome di stile."),
|
||||
("Corner Options", "Opzioni angolo"),
|
||||
("Keep In Frame", "Mantieni nella cornice"),
|
||||
("New Paragraph Style", "Nuovo stile di paragrafo"),
|
||||
("New Character Style", "Nuovo stile di carattere"),
|
||||
|
||||
@@ -32,6 +32,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Cut", "カット"),
|
||||
("Copy", "コピー"),
|
||||
("Paste", "ペースト"),
|
||||
("Apply Next Style", "次のスタイルを適用"),
|
||||
("Text Frame General Options", "テキストフレーム一般オプション"),
|
||||
("Stroke Type", "線の種類"),
|
||||
("Corner Shape", "角のシェイプ"),
|
||||
("Corner Size and Shape:", "角のサイズとシェイプ:"),
|
||||
|
||||
@@ -34,6 +34,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Cut", "ამოჭრა"),
|
||||
("Copy", "კოპირება"),
|
||||
("Paste", "ჩასმა"),
|
||||
("Apply Next Style", "შემდეგი სტილის გამოყენება"),
|
||||
("Text Frame General Options", "ტექსტის ჩარჩოს ზოგადი პარამეტრები"),
|
||||
("New Object Style", "ახალი ობიექტის სტილი"),
|
||||
("Object Style Options", "ობიექტის სტილის პარამეტრები"),
|
||||
("Basic Attributes", "ძირითადი ატრიბუტები"),
|
||||
("Stroke & Corner Options", "კონტურისა და კუთხეების პარამეტრები"),
|
||||
("Effects for:", "ეფექტები:"),
|
||||
("Create New Style", "ახალი სტილის შექმნა"),
|
||||
("Clear Overrides", "გადაფარვების გასუფთავება"),
|
||||
("Shape:", "ფორმა:"),
|
||||
("(no attributes)", "(ატრიბუტების გარეშე)"),
|
||||
("Enter a style name.", "შეიყვანეთ სტილის სახელი."),
|
||||
("Keep In Frame", "ჩარჩოში შენარჩუნება"),
|
||||
("New Paragraph Style", "აბზაცის ახალი სტილი"),
|
||||
("New Character Style", "სიმბოლოს ახალი სტილი"),
|
||||
|
||||
@@ -32,6 +32,19 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Cut", "Wytnij"),
|
||||
("Copy", "Kopiuj"),
|
||||
("Paste", "Wklej"),
|
||||
("Apply Next Style", "Zastosuj następny styl"),
|
||||
("Text Frame General Options", "Ogólne opcje ramki tekstowej"),
|
||||
("New Object Style", "Nowy styl obiektu"),
|
||||
("Object Style Options", "Opcje stylu obiektu"),
|
||||
("Object Style Options…", "Opcje stylu obiektu…"),
|
||||
("Basic Attributes", "Atrybuty podstawowe"),
|
||||
("Stroke & Corner Options", "Opcje obrysu i narożników"),
|
||||
("Effects for:", "Efekty dla:"),
|
||||
("Create New Style", "Utwórz nowy styl"),
|
||||
("Clear Overrides", "Wyczyść zastąpienia"),
|
||||
("Shape:", "Kształt:"),
|
||||
("(no attributes)", "(brak atrybutów)"),
|
||||
("Enter a style name.", "Wpisz nazwę stylu."),
|
||||
("Keep In Frame", "Zachowaj w ramce"),
|
||||
("Gap Tint:", "Odcień odstępu:"),
|
||||
("New Paragraph Style", "Nowy styl akapitowy"),
|
||||
|
||||
@@ -37,6 +37,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Cut", "Recortar"),
|
||||
("Copy", "Copiar"),
|
||||
("Paste", "Colar"),
|
||||
("Apply Next Style", "Aplicar próximo estilo"),
|
||||
("Text Frame General Options", "Opções gerais do quadro de texto"),
|
||||
("New Object Style", "Novo estilo de objeto"),
|
||||
("Object Style Options", "Opções de estilo de objeto"),
|
||||
("Basic Attributes", "Atributos básicos"),
|
||||
("Stroke & Corner Options", "Opções de traçado e canto"),
|
||||
("Effects for:", "Efeitos para:"),
|
||||
("Create New Style", "Criar novo estilo"),
|
||||
("Clear Overrides", "Limpar substituições"),
|
||||
("Shape:", "Forma:"),
|
||||
("(no attributes)", "(sem atributos)"),
|
||||
("Enter a style name.", "Digite um nome de estilo."),
|
||||
("Keep In Frame", "Manter no quadro"),
|
||||
("New Paragraph Style", "Novo estilo de parágrafo"),
|
||||
("New Character Style", "Novo estilo de caractere"),
|
||||
|
||||
@@ -36,6 +36,19 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Cut", "Вырезать"),
|
||||
("Copy", "Копировать"),
|
||||
("Paste", "Вставить"),
|
||||
("Apply Next Style", "Применить следующий стиль"),
|
||||
("Text Frame General Options", "Общие параметры текстового фрейма"),
|
||||
("New Object Style", "Новый стиль объекта"),
|
||||
("Object Style Options", "Параметры стиля объекта"),
|
||||
("Object Style Options…", "Параметры стиля объекта…"),
|
||||
("Basic Attributes", "Основные атрибуты"),
|
||||
("Stroke & Corner Options", "Параметры обводки и углов"),
|
||||
("Effects for:", "Эффекты для:"),
|
||||
("Create New Style", "Создать новый стиль"),
|
||||
("Clear Overrides", "Удалить переопределения"),
|
||||
("Shape:", "Форма:"),
|
||||
("(no attributes)", "(нет атрибутов)"),
|
||||
("Enter a style name.", "Введите имя стиля."),
|
||||
("Keep In Frame", "Сохранить во фрейме"),
|
||||
("Gap Tint:", "Оттенок пробелов:"),
|
||||
("New Paragraph Style", "Новый стиль абзаца"),
|
||||
|
||||
@@ -2973,6 +2973,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
"Links and Images: {found} linked, {modified} changed, {missing} not found",
|
||||
"Länkar och bilder: {found} länkade, {modified} ändrade, {missing} hittades inte",
|
||||
),
|
||||
("Apply Next Style", "Använd nästa stil"),
|
||||
("Text Frame General Options", "Allmänna alternativ för textram"),
|
||||
("New Object Style", "Ny objektstil"),
|
||||
("Object Style Options", "Alternativ för objektstil"),
|
||||
("Basic Attributes", "Grundläggande attribut"),
|
||||
("Stroke & Corner Options", "Kontur- och hörnalternativ"),
|
||||
("Effects for:", "Effekter för:"),
|
||||
("Create New Style", "Skapa ny stil"),
|
||||
("Clear Overrides", "Rensa lokala ändringar"),
|
||||
("Shape:", "Form:"),
|
||||
("(no attributes)", "(inga attribut)"),
|
||||
("Enter a style name.", "Ange ett stilnamn."),
|
||||
];
|
||||
|
||||
pub(super) const CONTEXT: &[(&str, &str, &str)] = &[
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
/// English → [German, French, Spanish, Japanese, Simplified Chinese].
|
||||
pub(super) const TABLE: &[(&str, [&str; 5])] = &[
|
||||
("Keep In Frame", ["Im Rahmen behalten", "Conserver dans le bloc", "Mantener en el marco", "フレーム内に保持", "保持在框架内"]),
|
||||
("Next Style:", ["Nächstes Format:", "Style suivant :", "Estilo siguiente:", "次のスタイル :", "下一样式:"]),
|
||||
("[Same style]", ["[Gleiches Format]", "[Même style]", "[Mismo estilo]", "[同じスタイル]", "[相同样式]"]),
|
||||
("Reset To Base", ["Auf Basis zurücksetzen", "Rétablir la base", "Restablecer a base", "基準に戻す", "重置为基准"]),
|
||||
("New Paragraph Style", ["Neues Absatzformat", "Nouveau style de paragraphe", "Nuevo estilo de párrafo", "新規段落スタイル", "新建段落样式"]),
|
||||
("New Character Style", ["Neues Zeichenformat", "Nouveau style de caractère", "Nuevo estilo de carácter", "新規文字スタイル", "新建字符样式"]),
|
||||
("New Object Style", ["Neues Objektformat", "Nouveau style d'objet", "Nuevo estilo de objeto", "新規オブジェクトスタイル", "新建对象样式"]),
|
||||
(
|
||||
"Object Style Options",
|
||||
["Objektformatoptionen", "Options de style d'objet", "Opciones de estilo de objeto", "オブジェクトスタイルの設定", "对象样式选项"],
|
||||
),
|
||||
(
|
||||
"Object Style Options…",
|
||||
["Objektformatoptionen…", "Options de style d'objet…", "Opciones de estilo de objeto…", "オブジェクトスタイルの設定…", "对象样式选项…"],
|
||||
),
|
||||
("Basic Attributes", ["Grundattribute", "Attributs de base", "Atributos básicos", "基本属性", "基本属性"]),
|
||||
(
|
||||
"Stroke & Corner Options",
|
||||
["Kontur- und Eckenoptionen", "Options de contour et d'angle", "Opciones de trazo y esquina", "線とコーナーのオプション", "描边和角选项"],
|
||||
),
|
||||
("Effects for:", ["Effekte für:", "Effets pour :", "Efectos para:", "効果 :", "效果:"]),
|
||||
(
|
||||
"Apply Style to Selection",
|
||||
[
|
||||
@@ -16,6 +25,26 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[
|
||||
"将样式应用于选区",
|
||||
],
|
||||
),
|
||||
("Create New Style", ["Neues Format erstellen", "Créer un style", "Crear nuevo estilo", "新規スタイルを作成", "创建新样式"]),
|
||||
("Clear Overrides", ["Abweichungen löschen", "Effacer les remplacements", "Borrar modificaciones", "オーバーライドを消去", "清除覆盖"]),
|
||||
("Shape:", ["Form:", "Forme :", "Forma:", "形状 :", "形状:"]),
|
||||
("(no attributes)", ["(keine Attribute)", "(aucun attribut)", "(sin atributos)", "(属性なし)", "(无属性)"]),
|
||||
(
|
||||
"Enter a style name.",
|
||||
[
|
||||
"Geben Sie einen Formatnamen ein.",
|
||||
"Saisissez un nom de style.",
|
||||
"Escriba un nombre de estilo.",
|
||||
"スタイル名を入力してください。",
|
||||
"请输入样式名称。",
|
||||
],
|
||||
),
|
||||
("Keep In Frame", ["Im Rahmen behalten", "Conserver dans le bloc", "Mantener en el marco", "フレーム内に保持", "保持在框架内"]),
|
||||
("Next Style:", ["Nächstes Format:", "Style suivant :", "Estilo siguiente:", "次のスタイル :", "下一样式:"]),
|
||||
("[Same style]", ["[Gleiches Format]", "[Même style]", "[Mismo estilo]", "[同じスタイル]", "[相同样式]"]),
|
||||
("Reset To Base", ["Auf Basis zurücksetzen", "Rétablir la base", "Restablecer a base", "基準に戻す", "重置为基准"]),
|
||||
("New Paragraph Style", ["Neues Absatzformat", "Nouveau style de paragraphe", "Nuevo estilo de párrafo", "新規段落スタイル", "新建段落样式"]),
|
||||
("New Character Style", ["Neues Zeichenformat", "Nouveau style de caractère", "Nuevo estilo de carácter", "新規文字スタイル", "新建字符样式"]),
|
||||
(
|
||||
"Clear Frame Fitting Options",
|
||||
[
|
||||
@@ -1442,6 +1471,20 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[
|
||||
"Hyphenate Across Column",
|
||||
["Über Spalten trennen", "Césure entre colonnes", "Separar entre columnas", "段をまたいでハイフネーション", "跨栏连字"],
|
||||
),
|
||||
(
|
||||
"Apply Next Style",
|
||||
["Nächstes Format anwenden", "Appliquer le style suivant", "Aplicar estilo siguiente", "次のスタイルを適用", "应用下一样式"],
|
||||
),
|
||||
(
|
||||
"Text Frame General Options",
|
||||
[
|
||||
"Allgemeine Textrahmenoptionen",
|
||||
"Options générales du bloc de texte",
|
||||
"Opciones generales de marco de texto",
|
||||
"テキストフレーム一般オプション",
|
||||
"文本框架常规选项",
|
||||
],
|
||||
),
|
||||
(
|
||||
"Differs from this document",
|
||||
["Weicht von diesem Dokument ab", "Diffère de ce document", "Difiere de este documento", "このドキュメントと異なります", "与此文档不同"],
|
||||
|
||||
@@ -35,6 +35,20 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Cut", "Вирізати"),
|
||||
("Copy", "Копіювати"),
|
||||
("Paste", "Вставити"),
|
||||
("Apply Next Style", "Застосувати наступний стиль"),
|
||||
("Text Frame General Options", "Загальні параметри текстового фрейму"),
|
||||
("New Object Style", "Новий стиль об'єкта"),
|
||||
("Object Style Options", "Параметри стилю об'єкта"),
|
||||
("Object Style Options…", "Параметри стилю об'єкта…"),
|
||||
("Basic Attributes", "Основні атрибути"),
|
||||
("Stroke & Corner Options", "Параметри обведення й кутів"),
|
||||
("Effects for:", "Ефекти для:"),
|
||||
("Create New Style", "Створити новий стиль"),
|
||||
("Clear Overrides", "Очистити перевизначення"),
|
||||
("Shape:", "Форма:"),
|
||||
("(no attributes)", "(немає атрибутів)"),
|
||||
("Enter a style name.", "Введіть назву стилю."),
|
||||
("Corner Options", "Параметри кутів"),
|
||||
("Keep In Frame", "Зберегти у фреймі"),
|
||||
("New Paragraph Style", "Новий стиль абзацу"),
|
||||
("New Character Style", "Новий стиль символів"),
|
||||
|
||||
@@ -34,6 +34,7 @@ pub mod menus;
|
||||
mod modal_drag;
|
||||
mod new_document;
|
||||
mod object_control;
|
||||
pub mod object_style_dialog;
|
||||
pub mod package_dialog;
|
||||
mod page_history;
|
||||
mod panel_docking;
|
||||
|
||||
@@ -285,6 +285,13 @@ fn run_ui_part1(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
|
||||
crate::effects_dialog::open(app, p.get("page").and_then(Value::as_str).unwrap_or("transparency"));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"objectStyle.dialog" => match p.get("name").and_then(Value::as_str) {
|
||||
Some(n) => crate::object_style_dialog::open_edit(app, n).map(|()| Value::Null),
|
||||
None => {
|
||||
crate::object_style_dialog::open_new(app);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
},
|
||||
"app.loadSwatches" => {
|
||||
if let Some(path) = p.get("path").and_then(Value::as_str) {
|
||||
return Some(app.run("swatch.load", json!({"path": path})));
|
||||
|
||||
@@ -608,6 +608,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
||||
"ui:window.panel|Stroke|{\"panel\": \"stroke\"}",
|
||||
">Styles",
|
||||
"ui:window.panel|Character Styles|{\"panel\": \"characterStyles\"}",
|
||||
"ui:window.panel|Object Styles|{\"panel\": \"objectStyles\"}",
|
||||
"ui:window.panel|Paragraph Styles|{\"panel\": \"paragraphStyles\"}",
|
||||
"<",
|
||||
"ui:window.panel|Text Wrap|{\"panel\": \"textWrap\"}",
|
||||
|
||||
@@ -35,6 +35,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
None,
|
||||
"{page?: transparency|dropShadow|innerShadow|outerGlow|innerGlow|bevel|satin|feather|directionalFeather|gradientFeather} — the Effects dialog for the selected objects",
|
||||
),
|
||||
(
|
||||
"objectStyle.dialog",
|
||||
"Object Style…",
|
||||
None,
|
||||
"{name?: style} — Object Style Options for the style `name`, or (without it) New Object Style from the selected object",
|
||||
),
|
||||
(
|
||||
"app.packageDialog",
|
||||
"Package…",
|
||||
|
||||
@@ -0,0 +1,889 @@
|
||||
//! The Object Styles panel's New Object Style and Object Style Options dialog, laid out as
|
||||
//! InDesign's: the style's name on top; on the left its Basic Attributes (a checkbox includes one
|
||||
//! in the style, a click shows its settings) and its effects; on the right the settings of the
|
||||
//! attribute shown (General sums the style up); Apply Style to Selection for a new style.
|
||||
//!
|
||||
//! Paragraph Styles nests a paragraph style in the object style (the course's chapter 39), and
|
||||
//! Text Frame General Options is the Text Frame Options dialog's General page.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::DesignApp;
|
||||
use crate::dialogs::Dialog;
|
||||
use crate::theme::semibold;
|
||||
|
||||
/// The dialog's id.
|
||||
pub const ID: &str = "objectStyle";
|
||||
|
||||
/// Basic Attributes: (key, label), in InDesign's order. General isn't an attribute: it sums the
|
||||
/// style up.
|
||||
const ATTRIBUTES: [(&str, &str); 6] = [
|
||||
("general", "General"),
|
||||
("fill", "Fill"),
|
||||
("stroke", "Stroke"),
|
||||
("corners", "Stroke & Corner Options"),
|
||||
("paragraph", "Paragraph Styles"),
|
||||
("textFrame", "Text Frame General Options"),
|
||||
];
|
||||
|
||||
/// The fields of the Paragraph Styles and Text Frame General Options pages: the style's paragraph
|
||||
/// style (`""`: none), Apply Next Style, and text frame options `tf` as the Text Frame Options
|
||||
/// dialog shows them (`tfBase` keeps the options the page doesn't show).
|
||||
fn text_fields(app: &DesignApp, fields: &mut Value, paragraph: Option<&str>, next: bool, tf: &designcraft_doc::TextFrameOptions) {
|
||||
let units = app.session.active().map_or(designcraft_geom::Unit::Picas, |s| s.doc.settings.horizontal_units);
|
||||
fields["paragraphStyle"] = json!(paragraph.unwrap_or(""));
|
||||
fields["applyNext"] = json!(next);
|
||||
fields["tfBase"] = serde_json::to_value(tf).unwrap_or_default();
|
||||
fields["insetLinked"] = json!(tf.inset.iter().all(|v| (v - tf.inset[0]).abs() < 1e-9));
|
||||
if let (Value::Object(f), Value::Object(shown)) =
|
||||
(fields, crate::dialogs::text_frame_fields(tf, units, None, &crate::dialogs::document_grid(app)))
|
||||
{
|
||||
f.extend(shown);
|
||||
}
|
||||
}
|
||||
|
||||
/// The active document as the dialog records it: its uid and the epoch that File › Revert
|
||||
/// changes.
|
||||
fn document_key(app: &DesignApp) -> Value {
|
||||
app.session.active().map_or(Value::Null, |st| json!([st.uid, st.identity_epoch]))
|
||||
}
|
||||
|
||||
/// The document the dialog was opened on is still the active one and was not reverted since, so
|
||||
/// OK never gives its settings to another document's style of the same name.
|
||||
fn same_document(app: &DesignApp, d: &Dialog) -> Result<(), String> {
|
||||
let key = document_key(app);
|
||||
if !key.is_null() && d.fields.get("doc") == Some(&key) { Ok(()) } else { Err(format!("the active document changed after {} opened", title(d))) }
|
||||
}
|
||||
|
||||
/// The dialog for a new style from the selected object (its fill, stroke, corners and effects,
|
||||
/// as InDesign's New Object Style), named "Object Style n".
|
||||
pub fn open_new(app: &mut DesignApp) {
|
||||
let doc = document_key(app);
|
||||
let Some(st) = app.session.active() else { return };
|
||||
// Apply Style to Selection applies the style to the objects selected now.
|
||||
let targets: Vec<u64> = st.selection.items.iter().map(|i| i.0).collect();
|
||||
let it = st.selection.items.first().and_then(|i| st.doc.item(*i)).cloned();
|
||||
let taken = |n: &str| st.doc.styles.object_style(n).is_some();
|
||||
let name = (1..).map(|i| format!("Object Style {i}")).find(|n| !taken(n)).unwrap_or_default();
|
||||
let from = it.is_some();
|
||||
// A text frame's options come along; its paragraph style is left to choose, as in InDesign.
|
||||
let frame = it.as_ref().and_then(|i| i.text_frame()).map(|t| t.options.clone());
|
||||
let mut fields = json!({
|
||||
"new": true,
|
||||
"doc": doc,
|
||||
"targets": targets,
|
||||
"name": name,
|
||||
"basedOn": designcraft_doc::NO_OBJECT_STYLE,
|
||||
"fill": it.as_ref().map(|i| i.fill.clone()).unwrap_or_default(),
|
||||
"stroke": it.as_ref().map(|i| i.stroke.clone()).unwrap_or_default(),
|
||||
// A style holds named corners (top left, top right, …), the item its path's.
|
||||
"corners": it.as_ref().map(|i| i.corners.path_to_named(&i.path)).unwrap_or_default(),
|
||||
"fx": crate::effects_dialog::effects_json(&it.map(|i| i.effects).unwrap_or_default()),
|
||||
"hadEffects": from,
|
||||
"inc": {"fill": from, "stroke": from, "corners": from, "paragraph": from, "textFrame": frame.is_some()},
|
||||
"page": "general",
|
||||
"apply": from,
|
||||
});
|
||||
text_fields(app, &mut fields, None, false, &frame.unwrap_or_default());
|
||||
app.ui.dialog = Some(Dialog::new(ID, fields));
|
||||
}
|
||||
|
||||
/// Object Style Options for style `name`; refused for a style the document doesn't have and for
|
||||
/// `[None]`, which has no options.
|
||||
pub fn open_edit(app: &mut DesignApp, name: &str) -> Result<(), String> {
|
||||
let Some(os) = app.session.active().and_then(|st| st.doc.styles.object_style(name).cloned()) else {
|
||||
return Err(format!("no object style `{name}`"));
|
||||
};
|
||||
if name == designcraft_doc::NO_OBJECT_STYLE {
|
||||
return Err(format!("`{name}` has no options"));
|
||||
}
|
||||
let mut fields = json!({
|
||||
"new": false,
|
||||
"doc": document_key(app),
|
||||
"orig": name,
|
||||
"name": name,
|
||||
"basedOn": os.based_on.as_deref().unwrap_or(designcraft_doc::NO_OBJECT_STYLE),
|
||||
"fill": os.fill.clone().unwrap_or_default(),
|
||||
"stroke": os.stroke.clone().unwrap_or_default(),
|
||||
"corners": os.corners.unwrap_or_default(),
|
||||
"fx": crate::effects_dialog::effects_json(&os.effects.clone().unwrap_or_default()),
|
||||
"hadEffects": os.effects.is_some(),
|
||||
"inc": {
|
||||
"fill": os.fill.is_some(), "stroke": os.stroke.is_some(), "corners": os.corners.is_some(),
|
||||
"paragraph": os.paragraph_style.is_some(), "textFrame": os.text_frame.is_some(),
|
||||
},
|
||||
"page": "general",
|
||||
"apply": false,
|
||||
});
|
||||
text_fields(app, &mut fields, os.paragraph_style.as_deref(), os.apply_next_style, &os.text_frame.clone().unwrap_or_default());
|
||||
app.ui.dialog = Some(Dialog::new(ID, fields));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The dialog's title: New Object Style or Object Style Options.
|
||||
pub fn title(d: &Dialog) -> &'static str {
|
||||
if d.b("new") { "New Object Style" } else { "Object Style Options" }
|
||||
}
|
||||
|
||||
fn included(d: &Dialog, key: &str) -> bool {
|
||||
d.fields.get("inc").and_then(|i| i[key].as_bool()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether the style sets effects: one is on now, or (editing) the style set them already. A new
|
||||
/// style with every effect off sets none, so applying it leaves the objects' own effects.
|
||||
fn sets_effects(d: &Dialog) -> bool {
|
||||
let fx = d.fields.get("fx").cloned().unwrap_or(Value::Null);
|
||||
(!d.b("new") && d.b("hadEffects")) || crate::effects_dialog::PAGES.iter().skip(1).any(|(k, _)| crate::effects_dialog::is_on(&fx, k))
|
||||
}
|
||||
|
||||
/// The style's attributes as the commands take them (null: not in the style).
|
||||
fn attrs(d: &Dialog) -> serde_json::Map<String, Value> {
|
||||
let mut p = serde_json::Map::new();
|
||||
for k in ["fill", "stroke", "corners"] {
|
||||
p.insert(k.into(), if included(d, k) { d.fields.get(k).cloned().unwrap_or(Value::Null) } else { Value::Null });
|
||||
}
|
||||
p.insert("effects".into(), if sets_effects(d) { d.fields.get("fx").cloned().unwrap_or(Value::Null) } else { Value::Null });
|
||||
let paragraph = d.s("paragraphStyle");
|
||||
p.insert("paragraphStyle".into(), if included(d, "paragraph") && !paragraph.is_empty() { json!(paragraph) } else { Value::Null });
|
||||
p.insert("applyNextStyle".into(), json!(d.b("applyNext")));
|
||||
p
|
||||
}
|
||||
|
||||
/// The Text Frame General Options page as the style's text frame options (the options it doesn't
|
||||
/// show as they were), or `None` when the style doesn't set them.
|
||||
fn frame_options(d: &Dialog) -> Result<Option<designcraft_doc::TextFrameOptions>, String> {
|
||||
if !included(d, "textFrame") {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut o: designcraft_doc::TextFrameOptions = d.fields.get("tfBase").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
|
||||
let bad = |what: &str| format!("Text Frame General Options: {what}");
|
||||
let measure = |key: &str| d.m(key).filter(|v| v.is_finite() && (0.0..=15552.0).contains(v));
|
||||
o.columns = d
|
||||
.n("columns")
|
||||
.filter(|n| (1.0..=40.0).contains(n) && n.fract() == 0.0)
|
||||
.ok_or_else(|| bad("the number of columns is a whole number from 1 to 40"))? as u32;
|
||||
o.gutter = measure("gutter").ok_or_else(|| bad("the gutter is not a measure"))?;
|
||||
o.columns_kind = serde_json::from_value(json!(d.s("columnsKind"))).unwrap_or(o.columns_kind);
|
||||
if o.columns_kind == designcraft_doc::ColumnsKind::FixedWidth {
|
||||
o.column_width = measure("columnWidth").ok_or_else(|| bad("the column width is not a measure"))?;
|
||||
}
|
||||
o.balance_columns = d.b("balanceColumns");
|
||||
for (side, key) in o.inset.iter_mut().zip(["insetTop", "insetLeft", "insetBottom", "insetRight"]) {
|
||||
*side = measure(key).ok_or_else(|| bad("an inset is not a measure"))?;
|
||||
}
|
||||
o.vertical_justification = serde_json::from_value(json!(d.s("verticalJustification"))).unwrap_or(o.vertical_justification);
|
||||
if o.vertical_justification == designcraft_doc::VerticalJustification::Justify {
|
||||
o.vj_paragraph_spacing_limit = measure("paragraphSpacingLimit").ok_or_else(|| bad("the paragraph spacing limit is not a measure"))?;
|
||||
}
|
||||
o.ignore_wrap = d.b("ignoreWrap");
|
||||
Ok(Some(o))
|
||||
}
|
||||
|
||||
/// The General options of `o` as the `textFrame` parameter of `style.object.create` and `edit`.
|
||||
fn general_json(o: &designcraft_doc::TextFrameOptions) -> Value {
|
||||
let mut v = serde_json::to_value(o).unwrap_or_default();
|
||||
if let Value::Object(m) = &mut v {
|
||||
m.retain(|k, _| designcraft_doc::TextFrameOptions::GENERAL_KEYS.contains(&k.as_str()));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// OK: the new style (applied to the selection when asked), or the edited one.
|
||||
pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result<Value, String> {
|
||||
same_document(app, d)?;
|
||||
let name = d.s("name").trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(crate::i18n::tr(&app.ui.language, "Enter a style name.").to_string());
|
||||
}
|
||||
let mut p = attrs(d);
|
||||
p.insert("textFrame".into(), frame_options(d)?.map_or(Value::Null, |o| general_json(&o)));
|
||||
if d.b("new") {
|
||||
p.insert("name".into(), json!(name));
|
||||
p.insert("fromSelection".into(), json!(false));
|
||||
let r = app.run("style.object.create", Value::Object(p)).map_err(|e| e.to_string())?;
|
||||
let made = r["name"].as_str().unwrap_or(&name).to_string();
|
||||
// The style exists now, so OK again mustn't make a second one: a refused apply (locked
|
||||
// objects) goes to the status line and the dialog closes. The style goes to the objects
|
||||
// selected when the dialog opened that still exist, not to a selection made since.
|
||||
let targets: Vec<u64> = app.session.active().map_or_else(Vec::new, |st| {
|
||||
d.fields
|
||||
.get("targets")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_u64)
|
||||
.filter(|id| st.doc.item(designcraft_doc::ItemId(*id)).is_some())
|
||||
.collect()
|
||||
});
|
||||
if d.b("apply")
|
||||
&& !targets.is_empty()
|
||||
&& let Err(e) = app.run("style.object.apply", json!({"name": made, "ids": targets}))
|
||||
{
|
||||
app.status(e.to_string());
|
||||
}
|
||||
Ok(r)
|
||||
} else {
|
||||
let orig = d.s("orig");
|
||||
p.insert("name".into(), json!(orig));
|
||||
if name != orig {
|
||||
p.insert("rename".into(), json!(name));
|
||||
}
|
||||
// A style that didn't set effects and still doesn't: nothing to say about them.
|
||||
if p.get("effects").is_some_and(Value::is_null) && !d.b("hadEffects") {
|
||||
p.remove("effects");
|
||||
}
|
||||
// Apply Next Style means nothing without a paragraph style.
|
||||
if p.get("paragraphStyle").is_some_and(Value::is_null) {
|
||||
p.remove("applyNextStyle");
|
||||
}
|
||||
app.run("style.object.edit", Value::Object(p)).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn set(d: &mut Dialog, path: &[&str], v: Value) {
|
||||
let Some((first, rest)) = path.split_first() else { return };
|
||||
let mut cur = d.fields.entry(first.to_string()).or_insert(Value::Null);
|
||||
for k in rest {
|
||||
if !cur.is_object() {
|
||||
*cur = json!({});
|
||||
}
|
||||
cur = &mut cur[*k];
|
||||
}
|
||||
*cur = v;
|
||||
}
|
||||
|
||||
fn get<'a>(d: &'a Dialog, path: &[&str]) -> &'a Value {
|
||||
let Some((first, rest)) = path.split_first() else { return &Value::Null };
|
||||
let mut v = d.fields.get(*first).unwrap_or(&Value::Null);
|
||||
for k in rest {
|
||||
v = &v[*k];
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A row of the left-hand lists: a checkbox (when `check` is given) and the label, which shows
|
||||
/// `page`.
|
||||
fn list_row(ui: &mut egui::Ui, d: &mut Dialog, page: &str, label: &str, check: Option<bool>) -> Option<bool> {
|
||||
let mut out = None;
|
||||
ui.horizontal(|ui| {
|
||||
match check {
|
||||
Some(mut on) => {
|
||||
if ui.checkbox(&mut on, "").changed() {
|
||||
out = Some(on);
|
||||
}
|
||||
}
|
||||
None => ui.add_space(24.0),
|
||||
}
|
||||
let cur = d.fields.get("page").and_then(Value::as_str) == Some(page);
|
||||
if ui.selectable_label(cur, crate::rtl::widget(ui, label)).clicked() {
|
||||
d.fields.insert("page".into(), json!(page));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The document's swatches, as a list to pick from (InDesign's Fill and Stroke pages).
|
||||
fn swatch_list(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, key: &str) {
|
||||
let Some(doc) = app.session.active().map(|st| &st.doc) else { return };
|
||||
let cur = get(d, &[key, "swatch"]).as_str().unwrap_or("").to_string();
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.divider)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt(("os_swatches", key.to_string())).max_height(220.0).show(ui, |ui| {
|
||||
ui.set_min_width(ui.available_width());
|
||||
for w in doc.swatches.iter().filter(|w| !w.hidden || w.name == cur) {
|
||||
let picked = ui
|
||||
.horizontal(|ui| {
|
||||
let (c, g) = crate::widgets::swatch_colors(doc, &w.name, 1.0);
|
||||
let r = crate::widgets::swatch_chip(ui, 14.0, c, g);
|
||||
ui.selectable_label(w.name == cur, &w.name).clicked() || r.clicked()
|
||||
})
|
||||
.inner;
|
||||
if picked {
|
||||
set(d, &[key, "swatch"], json!(w.name));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A number field of the dialog at `path` (a percentage of 0–1 with `percent`).
|
||||
fn number(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], suffix: &str, range: (f64, f64), percent: bool) {
|
||||
let v = get(d, path).as_f64().unwrap_or(0.0);
|
||||
let shown = if percent { (v * 100.0).round() } else { v };
|
||||
let id = format!("os_{}", path.join("_"));
|
||||
let decimals = if percent { 0 } else { 2 };
|
||||
if let Some(n) = crate::widgets::NumField::number(&id, Some(shown), suffix, decimals).width(90.0).spinner().range(range.0, range.1).show(ui) {
|
||||
set(d, path, json!(if percent { n / 100.0 } else { n }));
|
||||
}
|
||||
}
|
||||
|
||||
/// One line per attribute the style sets (InDesign's Style Settings).
|
||||
fn settings(app: &DesignApp, d: &Dialog) -> Vec<String> {
|
||||
let tr = |s: &str| crate::i18n::tr(&app.ui.language, s).to_string();
|
||||
let mut out = Vec::new();
|
||||
if included(d, "fill") {
|
||||
let f = get(d, &["fill"]);
|
||||
out.push(format!("{}: {} {:.0}%", tr("Fill"), f["swatch"].as_str().unwrap_or(""), f["tint"].as_f64().unwrap_or(1.0) * 100.0));
|
||||
}
|
||||
if included(d, "stroke") {
|
||||
let s = get(d, &["stroke"]);
|
||||
out.push(format!("{}: {}, {} pt", tr("Stroke"), s["swatch"].as_str().unwrap_or(""), s["weight"].as_f64().unwrap_or(0.0)));
|
||||
}
|
||||
if included(d, "corners") {
|
||||
let c = &get(d, &["corners"])["corners"][0];
|
||||
let shape: designcraft_geom::corners::CornerShape = serde_json::from_value(c["shape"].clone()).unwrap_or_default();
|
||||
out.push(format!("{}: {} {} pt", tr("Corner Options"), tr(shape.label()), c["size"].as_f64().unwrap_or(0.0)));
|
||||
}
|
||||
let paragraph = d.s("paragraphStyle");
|
||||
if included(d, "paragraph") && !paragraph.is_empty() {
|
||||
let next = if d.b("applyNext") { format!(", {}", tr("Apply Next Style")) } else { String::new() };
|
||||
out.push(format!("{}: {}{next}", tr("Paragraph Styles"), crate::i18n::style_name(&app.ui.language, ¶graph)));
|
||||
}
|
||||
if included(d, "textFrame") {
|
||||
let insets: Vec<String> = ["insetTop", "insetLeft", "insetBottom", "insetRight"].iter().map(|k| d.s(k)).collect();
|
||||
let inset = if insets.iter().all(|i| *i == insets[0]) { insets[0].clone() } else { insets.join(", ") };
|
||||
out.push(format!(
|
||||
"{}: {} {}, {} {}, {}: {inset}",
|
||||
tr("Text Frame General Options"),
|
||||
tr("Columns:"),
|
||||
d.s("columns"),
|
||||
tr("Gutter:"),
|
||||
d.s("gutter"),
|
||||
tr("Inset Spacing")
|
||||
));
|
||||
}
|
||||
if sets_effects(d) {
|
||||
let fx = d.fields.get("fx").cloned().unwrap_or(Value::Null);
|
||||
let on: Vec<String> =
|
||||
crate::effects_dialog::PAGES.iter().skip(1).filter(|(k, _)| crate::effects_dialog::is_on(&fx, k)).map(|(_, l)| tr(l)).collect();
|
||||
out.push(format!("{}: {}", tr("Effects"), if on.is_empty() { tr("(no effects)") } else { on.join(", ") }));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The settings of the attribute shown.
|
||||
fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) {
|
||||
let tr = |s: &str| crate::i18n::tr(&app.ui.language, s).to_string();
|
||||
let title = ATTRIBUTES.iter().find(|a| a.0 == page).map(|a| a.1);
|
||||
if let Some(title) = title {
|
||||
crate::rtl::label(ui, egui::RichText::new(tr(title)).font(semibold(14.0)));
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
match page {
|
||||
"general" => {
|
||||
ui.horizontal(|ui| {
|
||||
crate::rtl::label(ui, tr("Based On:"));
|
||||
// Based On isn't editable yet: the style's parent, as it is.
|
||||
ui.add_enabled_ui(false, |ui| {
|
||||
let based = d.s("basedOn");
|
||||
craft_ui::ComboBox::from_id_salt("os_based_on")
|
||||
.selected_text(crate::i18n::style_name(&app.ui.language, &based))
|
||||
.width(220.0)
|
||||
.show_ui(ui, |_| {});
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
crate::rtl::label(ui, egui::RichText::new(tr("Style Settings:")).font(semibold(12.0)));
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.divider)).inner_margin(egui::Margin::same(8)).show(ui, |ui| {
|
||||
ui.set_min_width(ui.available_width());
|
||||
ui.set_min_height(160.0);
|
||||
let lines = settings(app, d);
|
||||
if lines.is_empty() {
|
||||
crate::rtl::label(ui, egui::RichText::new(tr("(no attributes)")).color(t.text_dim));
|
||||
}
|
||||
for l in lines {
|
||||
crate::rtl::label(ui, l);
|
||||
}
|
||||
});
|
||||
}
|
||||
"fill" | "stroke" => {
|
||||
let key = page;
|
||||
ui.add_enabled_ui(included(d, key), |ui| {
|
||||
swatch_list(app, ui, d, key);
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
if key == "stroke" {
|
||||
crate::rtl::label(ui, tr("Weight:"));
|
||||
number(ui, d, &["stroke", "weight"], " pt", (0.0, 1000.0), false);
|
||||
ui.add_space(12.0);
|
||||
}
|
||||
crate::rtl::label(ui, tr("Tint:"));
|
||||
number(ui, d, &[key, "tint"], "%", (0.0, 100.0), true);
|
||||
});
|
||||
});
|
||||
}
|
||||
"corners" => {
|
||||
ui.add_enabled_ui(included(d, "corners"), |ui| {
|
||||
crate::rtl::label(ui, egui::RichText::new(tr("Corner Options")).font(semibold(12.0)));
|
||||
ui.horizontal(|ui| {
|
||||
crate::rtl::label(ui, tr("Size:"));
|
||||
let size = get(d, &["corners", "corners"])[0]["size"].as_f64().unwrap_or(0.0);
|
||||
if let Some(v) =
|
||||
crate::widgets::NumField::number("os_corner_size", Some(size), " pt", 2).width(90.0).spinner().range(0.0, 10_000.0).show(ui)
|
||||
{
|
||||
let mut c = get(d, &["corners"]).clone();
|
||||
if let Some(cs) = c["corners"].as_array_mut() {
|
||||
cs.iter_mut().for_each(|k| k["size"] = json!(v));
|
||||
}
|
||||
d.fields.insert("corners".into(), c);
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
crate::rtl::label(ui, tr("Shape:"));
|
||||
let cur: designcraft_geom::corners::CornerShape =
|
||||
serde_json::from_value(get(d, &["corners", "corners"])[0]["shape"].clone()).unwrap_or_default();
|
||||
let mut picked = None;
|
||||
craft_ui::ComboBox::from_id_salt("os_corner_shape").selected_text(tr(cur.label())).width(140.0).show_ui(ui, |ui| {
|
||||
for s in designcraft_geom::corners::CornerShape::ALL {
|
||||
if ui.selectable_label(s == cur, tr(s.label())).clicked() {
|
||||
picked = Some(s);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(s) = picked {
|
||||
let mut c = get(d, &["corners"]).clone();
|
||||
if let Some(cs) = c["corners"].as_array_mut() {
|
||||
cs.iter_mut().for_each(|k| k["shape"] = json!(s));
|
||||
}
|
||||
d.fields.insert("corners".into(), c);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
"paragraph" => {
|
||||
let Some(doc) = app.session.active().map(|st| &st.doc) else { return };
|
||||
ui.add_enabled_ui(included(d, "paragraph"), |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
crate::rtl::label(ui, tr("Paragraph Style:"));
|
||||
let cur = d.s("paragraphStyle");
|
||||
let shown = if cur.is_empty() { String::new() } else { crate::i18n::style_name(&app.ui.language, &cur).to_string() };
|
||||
let mut picked = None;
|
||||
craft_ui::ComboBox::from_id_salt("os_paragraph_style").selected_text(shown).width(240.0).show_ui(ui, |ui| {
|
||||
for s in &doc.styles.paragraph {
|
||||
if ui.selectable_label(s.name == cur, crate::i18n::style_name(&app.ui.language, &s.name)).clicked() {
|
||||
picked = Some(s.name.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(name) = picked {
|
||||
d.fields.insert("paragraphStyle".into(), json!(name));
|
||||
}
|
||||
});
|
||||
let mut next = d.b("applyNext");
|
||||
if ui.checkbox(&mut next, crate::rtl::widget(ui, tr("Apply Next Style"))).changed() {
|
||||
d.fields.insert("applyNext".into(), json!(next));
|
||||
}
|
||||
});
|
||||
}
|
||||
"textFrame" => {
|
||||
ui.add_enabled_ui(included(d, "textFrame"), |ui| crate::dialogs::general(&app.ui.language, ui, d));
|
||||
}
|
||||
p => crate::effects_dialog::page_ui(app, ui, d, p),
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog's body.
|
||||
pub fn show(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let lang = app.ui.language.clone();
|
||||
let tr = |s: &str| crate::i18n::tr(&lang, s).to_string();
|
||||
ui.horizontal(|ui| {
|
||||
crate::rtl::label(ui, tr("Style Name:"));
|
||||
let mut name = d.s("name");
|
||||
if ui.add(egui::TextEdit::singleline(&mut name).desired_width(300.0)).changed() {
|
||||
d.fields.insert("name".into(), json!(name));
|
||||
}
|
||||
});
|
||||
// Why OK was refused (an empty or taken name), until the next try.
|
||||
let status = d.s("status");
|
||||
if !status.is_empty() {
|
||||
crate::rtl::label(ui, egui::RichText::new(status).color(crate::theme::Tokens::get(ui.ctx()).text_strong));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let page = d.fields.get("page").and_then(Value::as_str).unwrap_or("general").to_string();
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(230.0);
|
||||
crate::rtl::label(ui, egui::RichText::new(tr("Basic Attributes")).font(semibold(12.0)));
|
||||
for (key, label) in ATTRIBUTES {
|
||||
let check = (key != "general").then(|| included(d, key));
|
||||
if let Some(on) = list_row(ui, d, key, &tr(label), check) {
|
||||
set(d, &["inc", key], json!(on));
|
||||
}
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
crate::rtl::label(ui, tr("Effects for:"));
|
||||
// Stroke, Fill and Text levels aren't in the document model yet: the object only.
|
||||
ui.add_enabled_ui(false, |ui| {
|
||||
craft_ui::ComboBox::from_id_salt("os_fx_level").selected_text(tr("Object")).width(100.0).show_ui(ui, |_| {});
|
||||
});
|
||||
});
|
||||
for (key, label) in crate::effects_dialog::PAGES.iter().skip(1) {
|
||||
let fx = d.fields.get("fx").cloned().unwrap_or(Value::Null);
|
||||
if let Some(on) = list_row(ui, d, key, &tr(label), Some(crate::effects_dialog::is_on(&fx, key)))
|
||||
&& let Some(f) = d.fields.get_mut("fx")
|
||||
{
|
||||
if on {
|
||||
crate::effects_dialog::turn_on(f, key);
|
||||
} else if *key == "feather" {
|
||||
f["feather"] = json!(0.0);
|
||||
} else {
|
||||
f[*key]["on"] = json!(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.set_min_width(440.0);
|
||||
page_ui(app, ui, d, &page);
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
if d.b("new") {
|
||||
let mut apply = d.b("apply");
|
||||
if ui.checkbox(&mut apply, crate::rtl::widget(ui, tr("Apply Style to Selection"))).changed() {
|
||||
d.fields.insert("apply".into(), json!(apply));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A new style made from an object with no effect on sets no effects, so applying it keeps
|
||||
/// other objects' own.
|
||||
#[test]
|
||||
fn a_new_style_without_effects_on_sets_none() {
|
||||
let (mut app, _) = app_with_frame();
|
||||
open_new(&mut app);
|
||||
let d = app.ui.dialog.clone().unwrap();
|
||||
assert!(d.b("hadEffects"));
|
||||
assert_eq!(attrs(&d)["effects"], Value::Null);
|
||||
if let Some(d) = app.ui.dialog.as_mut() {
|
||||
d.fields.insert("name".into(), json!("Plain"));
|
||||
d.fields.insert("apply".into(), json!(false));
|
||||
}
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert!(app.session.doc().unwrap().doc.styles.object_style("Plain").unwrap().effects.is_none());
|
||||
}
|
||||
|
||||
fn app_with_frame() -> (DesignApp, u64) {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let id = app.run("frame.create", json!({"rect": [72, 72, 172, 172], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("selection.set", json!({"ids": [id]})).unwrap();
|
||||
(app, id)
|
||||
}
|
||||
|
||||
/// The texts the dialog paints, once its size has settled (it grows over a few frames).
|
||||
fn frame(app: &mut DesignApp, ctx: &egui::Context) -> Vec<String> {
|
||||
let mut texts = Vec::new();
|
||||
for _ in 0..8 {
|
||||
let input =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| crate::dialogs::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
fn walk(s: egui::epaint::Shape, texts: &mut Vec<String>) {
|
||||
match s {
|
||||
egui::epaint::Shape::Text(t) => texts.push(t.galley.text().to_string()),
|
||||
egui::epaint::Shape::Vec(v) => v.into_iter().for_each(|s| walk(s, texts)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
texts.clear();
|
||||
for c in out.shapes {
|
||||
walk(c.shape, &mut texts);
|
||||
}
|
||||
}
|
||||
texts
|
||||
}
|
||||
|
||||
/// The course's chapter 15: New Object Style from a frame with rounded corners, a paper
|
||||
/// stroke and a drop shadow, named and applied to the selection; then Object Style Options
|
||||
/// with a thicker stroke: the frames using the style follow.
|
||||
#[test]
|
||||
fn a_new_object_style_from_the_selection_and_its_options() {
|
||||
let (mut app, a) = app_with_frame();
|
||||
app.run("object.cornerOptions", json!({"shape": "rounded", "size": 12, "ids": [a]})).unwrap();
|
||||
app.run("object.stroke", json!({"swatch": "[Paper]", "weight": 2.5, "ids": [a]})).unwrap();
|
||||
app.run("object.dropShadow", json!({"on": true, "ids": [a]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx, "en");
|
||||
open_new(&mut app);
|
||||
let texts = frame(&mut app, &ctx);
|
||||
for t in [
|
||||
"New Object Style",
|
||||
"Style Name:",
|
||||
"Basic Attributes",
|
||||
"Fill",
|
||||
"Stroke & Corner Options",
|
||||
"Effects for:",
|
||||
"Drop Shadow",
|
||||
"Style Settings:",
|
||||
"Apply Style to Selection",
|
||||
] {
|
||||
assert!(texts.iter().any(|x| x.trim() == t), "{t}: {texts:?}");
|
||||
}
|
||||
assert!(texts.iter().any(|x| x.starts_with("Stroke: [Paper], 2.5 pt")), "{texts:?}");
|
||||
assert!(texts.iter().any(|x| x.starts_with("Effects: Drop Shadow")), "{texts:?}");
|
||||
if let Some(d) = app.ui.dialog.as_mut() {
|
||||
d.fields.insert("name".into(), json!("Rounded Corner Image, White Stroke"));
|
||||
d.fields.insert("page".into(), json!("corners"));
|
||||
}
|
||||
let texts = frame(&mut app, &ctx);
|
||||
assert!(texts.iter().any(|x| x.trim() == "Rounded"), "{texts:?}");
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let name = "Rounded Corner Image, White Stroke";
|
||||
let doc = |app: &DesignApp| app.session.active().unwrap().doc.clone();
|
||||
let item = |app: &DesignApp, id: u64| doc(app).item(designcraft_doc::ItemId(id)).unwrap().clone();
|
||||
assert_eq!(item(&app, a).object_style, name, "applied to the selection");
|
||||
let os = doc(&app).styles.object_style(name).cloned().unwrap();
|
||||
assert!(os.corners.is_some() && os.effects.as_ref().unwrap().drop_shadow.on && os.stroke.as_ref().unwrap().weight == 2.5);
|
||||
|
||||
let b = app.run("frame.create", json!({"rect": [200, 72, 300, 172], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("style.object.apply", json!({"name": name, "ids": [b]})).unwrap();
|
||||
open_edit(&mut app, name).unwrap();
|
||||
if let Some(d) = app.ui.dialog.as_mut() {
|
||||
assert_eq!(title(d), "Object Style Options");
|
||||
set(d, &["stroke", "weight"], json!(5.0));
|
||||
set(d, &["inc", "fill"], json!(false));
|
||||
}
|
||||
frame(&mut app, &ctx);
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!((item(&app, a).stroke.weight, item(&app, b).stroke.weight), (5.0, 5.0));
|
||||
assert!(doc(&app).styles.object_style(name).unwrap().fill.is_none(), "Fill unchecked: no longer in the style");
|
||||
assert!(doc(&app).styles.object_style(name).unwrap().effects.as_ref().unwrap().drop_shadow.on, "effects kept");
|
||||
}
|
||||
|
||||
/// The course's chapter 39: a new object style from a text frame nests the "Drop Cap First
|
||||
/// Paragraph" style (Paragraph Styles page), so the frame's text takes it; Object Style
|
||||
/// Options › Text Frame General Options then gives the style's frames insets and two columns.
|
||||
#[test]
|
||||
fn an_object_style_nests_a_paragraph_style_and_sets_text_frame_options() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("style.paragraph.create", json!({"name": "Body Text"})).unwrap();
|
||||
app.run("style.paragraph.create", json!({"name": "Drop Cap First Paragraph", "nextStyle": "Body Text", "para": {"dropCapLines": 3}}))
|
||||
.unwrap();
|
||||
let r = app.run("frame.create", json!({"rect": [36, 36, 336, 336], "content": "text", "caret": false})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
app.run("selection.set", json!({"ids": [id]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx, "en");
|
||||
open_new(&mut app);
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert!(included(d, "paragraph") && included(d, "textFrame"), "a text frame's categories are in a new style");
|
||||
assert_eq!(d.s("paragraphStyle"), "", "the paragraph style is left to choose");
|
||||
let texts = frame(&mut app, &ctx);
|
||||
for t in ["Paragraph Styles", "Text Frame General Options"] {
|
||||
assert!(texts.iter().any(|x| x.trim() == t), "{t}: {texts:?}");
|
||||
}
|
||||
if let Some(d) = app.ui.dialog.as_mut() {
|
||||
d.fields.insert("name".into(), json!("Text Box Drop Cap"));
|
||||
d.fields.insert("page".into(), json!("paragraph"));
|
||||
d.fields.insert("paragraphStyle".into(), json!("Drop Cap First Paragraph"));
|
||||
}
|
||||
let texts = frame(&mut app, &ctx);
|
||||
for t in ["Paragraph Style:", "Apply Next Style", "Drop Cap First Paragraph"] {
|
||||
assert!(texts.iter().any(|x| x.trim() == t), "{t}: {texts:?}");
|
||||
}
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let doc = |app: &DesignApp| app.session.active().unwrap().doc.clone();
|
||||
let story = |app: &DesignApp| {
|
||||
let d = doc(app);
|
||||
let sid = d.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().story;
|
||||
d.story(sid).unwrap().clone()
|
||||
};
|
||||
assert_eq!(doc(&app).item(designcraft_doc::ItemId(id)).unwrap().object_style, "Text Box Drop Cap");
|
||||
assert_eq!(story(&app).paras[0].style, "Drop Cap First Paragraph", "the frame's text takes the nested style");
|
||||
app.run("type.fillWithPlaceholder", json!({"frame": id})).unwrap();
|
||||
assert!(story(&app).paras.iter().all(|p| p.style == "Drop Cap First Paragraph"));
|
||||
|
||||
open_edit(&mut app, "Text Box Drop Cap").unwrap();
|
||||
let texts = frame(&mut app, &ctx);
|
||||
assert!(texts.iter().any(|x| x.starts_with("Paragraph Styles: Drop Cap First Paragraph")), "Style Settings: {texts:?}");
|
||||
if let Some(d) = app.ui.dialog.as_mut() {
|
||||
assert_eq!(d.s("paragraphStyle"), "Drop Cap First Paragraph");
|
||||
d.fields.insert("page".into(), json!("textFrame"));
|
||||
for k in ["insetTop", "insetLeft", "insetBottom", "insetRight"] {
|
||||
d.fields.insert(k.into(), json!("4 pt"));
|
||||
}
|
||||
d.fields.insert("columns".into(), json!("2"));
|
||||
}
|
||||
let texts = frame(&mut app, &ctx);
|
||||
for t in ["Inset Spacing", "Vertical Justification", "Ignore Text Wrap"] {
|
||||
assert!(texts.iter().any(|x| x.trim() == t), "{t}: {texts:?}");
|
||||
}
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let o = doc(&app).item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().options.clone();
|
||||
assert_eq!((o.columns, o.inset), (2, [4.0; 4]));
|
||||
let os = doc(&app).styles.object_style("Text Box Drop Cap").cloned().unwrap();
|
||||
assert_eq!((os.paragraph_style.as_deref(), os.text_frame.map(|t| t.columns)), (Some("Drop Cap First Paragraph"), Some(2)));
|
||||
|
||||
// A word for the gutter, or a fraction of a column, keeps the dialog open with the reason.
|
||||
for (field, value, reason) in [("gutter", "wide", "gutter"), ("columns", "2.5", "columns")] {
|
||||
open_edit(&mut app, "Text Box Drop Cap").unwrap();
|
||||
if let Some(d) = app.ui.dialog.as_mut() {
|
||||
d.fields.insert(field.into(), json!(value));
|
||||
}
|
||||
let e = crate::dialogs::confirm(&mut app).unwrap_err();
|
||||
assert!(e.contains(reason), "{e}");
|
||||
assert!(app.ui.dialog.is_some());
|
||||
app.ui.dialog = None;
|
||||
}
|
||||
let os = doc(&app).styles.object_style("Text Box Drop Cap").cloned().unwrap();
|
||||
assert_eq!(os.text_frame.map(|t| t.columns), Some(2), "2.5 columns changed nothing");
|
||||
}
|
||||
|
||||
/// Text Frame General Options › Vertical Justification: Justify with a Paragraph Spacing Limit
|
||||
/// sets both on the style and its frame, and the frame keeps its auto-size setting.
|
||||
#[test]
|
||||
fn the_general_options_page_sets_the_paragraph_spacing_limit() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let r = app.run("frame.create", json!({"rect": [36, 36, 336, 336], "content": "text", "caret": false})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
app.run("object.textFrameOptions", json!({"ids": [id], "autoSize": "heightOnly"})).unwrap();
|
||||
app.run("selection.set", json!({"ids": [id]})).unwrap();
|
||||
open_new(&mut app);
|
||||
if let Some(d) = app.ui.dialog.as_mut() {
|
||||
d.fields.insert("name".into(), json!("Justified"));
|
||||
d.fields.insert("verticalJustification".into(), json!("justify"));
|
||||
d.fields.insert("paragraphSpacingLimit".into(), json!("6 pt"));
|
||||
}
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let doc = app.session.active().unwrap().doc.clone();
|
||||
let tf = doc.styles.object_style("Justified").and_then(|s| s.text_frame.clone()).unwrap();
|
||||
assert_eq!((tf.vertical_justification, tf.vj_paragraph_spacing_limit), (designcraft_doc::VerticalJustification::Justify, 6.0));
|
||||
app.run("style.object.apply", json!({"name": "Justified", "ids": [id]})).unwrap();
|
||||
let o = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().options.clone();
|
||||
assert_eq!((o.vj_paragraph_spacing_limit, o.auto_size), (6.0, designcraft_doc::AutoSize::HeightOnly));
|
||||
}
|
||||
|
||||
/// `objectStyle.dialog` with a style the document lacks, or `[None]`, is an error and opens
|
||||
/// nothing; without a name it opens New Object Style.
|
||||
#[test]
|
||||
fn the_dialog_command_refuses_a_style_with_no_options() {
|
||||
let (mut app, _) = app_with_frame();
|
||||
for name in ["Nope", designcraft_doc::NO_OBJECT_STYLE] {
|
||||
assert!(app.run("objectStyle.dialog", json!({"name": name})).is_err(), "{name}");
|
||||
assert!(app.ui.dialog.is_none(), "{name}");
|
||||
}
|
||||
app.run("objectStyle.dialog", json!({})).unwrap();
|
||||
assert!(app.ui.dialog.as_ref().is_some_and(|d| d.b("new")));
|
||||
}
|
||||
|
||||
/// A refused OK (an empty name, a name another style has) keeps the dialog and its edits,
|
||||
/// with the reason shown.
|
||||
#[test]
|
||||
fn a_refused_ok_keeps_the_dialog_open_with_the_reason() {
|
||||
let (mut app, _) = app_with_frame();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx, "en");
|
||||
app.run("style.object.create", json!({"name": "Taken", "fromSelection": false})).unwrap();
|
||||
app.run("style.object.create", json!({"name": "Mine", "fromSelection": false})).unwrap();
|
||||
open_new(&mut app);
|
||||
if let Some(d) = app.ui.dialog.as_mut() {
|
||||
d.fields.insert("name".into(), json!(" "));
|
||||
set(d, &["stroke", "weight"], json!(7.0));
|
||||
}
|
||||
assert!(crate::dialogs::confirm(&mut app).is_err());
|
||||
let d = app.ui.dialog.as_ref().expect("still open");
|
||||
assert_eq!(d.s("status"), "Enter a style name.");
|
||||
assert_eq!(get(d, &["stroke", "weight"]), &json!(7.0), "the edits are kept");
|
||||
assert!(frame(&mut app, &ctx).iter().any(|t| t.trim() == "Enter a style name."));
|
||||
open_edit(&mut app, "Mine").unwrap();
|
||||
if let Some(d) = app.ui.dialog.as_mut() {
|
||||
d.fields.insert("name".into(), json!("Taken"));
|
||||
}
|
||||
let e = crate::dialogs::confirm(&mut app).unwrap_err();
|
||||
assert!(e.contains("already named"), "{e}");
|
||||
assert!(app.ui.dialog.as_ref().is_some_and(|d| d.s("status") == e));
|
||||
}
|
||||
|
||||
/// Object Style Options and New Object Style act only on the document they opened for. With
|
||||
/// another document active, after their document closed, or after it was reverted, OK keeps
|
||||
/// the dialog open with the reason and changes no style.
|
||||
#[test]
|
||||
fn the_dialog_acts_only_on_the_document_it_opened_for() {
|
||||
let name = designcraft_doc::BASIC_GRAPHICS_FRAME;
|
||||
let weights = |app: &DesignApp| -> Vec<f64> {
|
||||
app.session
|
||||
.documents()
|
||||
.iter()
|
||||
.map(|st| st.doc.styles.object_style(name).and_then(|s| s.stroke.as_ref()).map_or(-1.0, |s| s.weight))
|
||||
.collect()
|
||||
};
|
||||
let edit = |app: &mut DesignApp| {
|
||||
open_edit(app, name).unwrap();
|
||||
if let Some(d) = app.ui.dialog.as_mut() {
|
||||
set(d, &["stroke", "weight"], json!(9.0));
|
||||
}
|
||||
};
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"title": "A"})).unwrap();
|
||||
app.run("file.new", json!({"title": "B"})).unwrap();
|
||||
app.session.set_active(0);
|
||||
let before = weights(&app);
|
||||
assert_eq!(before.len(), 2);
|
||||
|
||||
// Options opened on A, OK with B active: B has a style of the same name.
|
||||
edit(&mut app);
|
||||
app.session.set_active(1);
|
||||
let e = crate::dialogs::confirm(&mut app).unwrap_err();
|
||||
assert!(e.contains("active document changed"), "{e}");
|
||||
assert!(app.ui.dialog.as_ref().is_some_and(|d| d.s("status") == e), "the dialog stays open with the reason");
|
||||
assert_eq!(weights(&app), before, "neither document's style changed");
|
||||
|
||||
// New Object Style opened on A makes nothing in B, and OK works once A is active again.
|
||||
app.ui.dialog = None;
|
||||
app.session.set_active(0);
|
||||
open_new(&mut app);
|
||||
app.session.set_active(1);
|
||||
assert!(crate::dialogs::confirm(&mut app).is_err());
|
||||
assert!(app.session.documents().iter().all(|st| st.doc.styles.object_style("Object Style 1").is_none()));
|
||||
app.session.set_active(0);
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert!(app.session.documents()[0].doc.styles.object_style("Object Style 1").is_some());
|
||||
assert!(app.session.documents()[1].doc.styles.object_style("Object Style 1").is_none());
|
||||
|
||||
// Options opened on A, A closed: B is active and keeps its style.
|
||||
edit(&mut app);
|
||||
app.session.execute("file.close", &json!({"index": 0})).unwrap();
|
||||
assert!(crate::dialogs::confirm(&mut app).is_err());
|
||||
assert_eq!(weights(&app), vec![before[1]]);
|
||||
app.ui.dialog = None;
|
||||
|
||||
// Options opened on B, B reverted: the reverted document keeps its style.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
app.run("file.saveAs", json!({"path": dir.path().join("revert.designcraft")})).unwrap();
|
||||
app.run("frame.create", json!({"rect": [72, 72, 144, 144], "content": "graphic"})).unwrap();
|
||||
edit(&mut app);
|
||||
app.run("file.revert", json!({})).unwrap();
|
||||
assert!(crate::dialogs::confirm(&mut app).is_err());
|
||||
assert_eq!(weights(&app), vec![before[1]]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply Style to Selection applies the new style to the objects selected when the dialog
|
||||
/// opened, not to a selection made while it was open.
|
||||
#[test]
|
||||
fn apply_style_to_selection_uses_the_selection_the_dialog_opened_with() {
|
||||
let (mut app, a) = app_with_frame();
|
||||
let b = app.run("frame.create", json!({"rect": [200, 72, 300, 172], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("selection.set", json!({"ids": [a]})).unwrap();
|
||||
open_new(&mut app);
|
||||
if let Some(d) = app.ui.dialog.as_mut() {
|
||||
d.fields.insert("name".into(), json!("Picked"));
|
||||
}
|
||||
app.run("selection.set", json!({"ids": [b]})).unwrap();
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let style = |id: u64| app.session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().object_style.clone();
|
||||
assert_eq!(style(a), "Picked");
|
||||
assert_ne!(style(b), "Picked");
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,7 @@ const ICON_GROUPS: &[&[&str]] = &[
|
||||
&["stroke", "color", "swatches", "gradient"],
|
||||
&["effects"],
|
||||
&["characterStyles", "paragraphStyles"],
|
||||
&["objectStyles"],
|
||||
&["character", "paragraph", "typography"],
|
||||
&["glyphs", "story"],
|
||||
&["table"],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Paragraph Styles and Character Styles panels.
|
||||
//! Paragraph Styles, Character Styles and Object Styles panels.
|
||||
|
||||
use egui::{Sense, vec2};
|
||||
use serde_json::json;
|
||||
@@ -105,6 +105,111 @@ pub fn character(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
list(app, ui, false);
|
||||
}
|
||||
|
||||
/// The selected object's style, and whether the object differs from it (an override: `+`): its
|
||||
/// own attributes, or a text frame's paragraphs in other styles than the object style gives them.
|
||||
fn object_style_of_selection(app: &DesignApp) -> Option<(String, bool)> {
|
||||
let st = app.session.active()?;
|
||||
let it = st.selection.items.first().and_then(|i| st.doc.item(*i))?;
|
||||
let mut styled = it.clone();
|
||||
let mut over = false;
|
||||
if let Some(os) = st.doc.styles.object_style(&it.object_style) {
|
||||
os.apply_to(&mut styled);
|
||||
if let Some(tf) = it.text_frame() {
|
||||
over = st.doc.story(tf.story).is_some_and(|s| !os.story_follows(&st.doc.styles, s));
|
||||
}
|
||||
}
|
||||
Some((it.object_style.clone(), over || styled != *it))
|
||||
}
|
||||
|
||||
/// Window › Styles › Object Styles, as InDesign's: the selected object's style on top (`+` when
|
||||
/// it is overridden), the document's object styles (a click applies one to the selection, a
|
||||
/// double-click opens its options; the default graphic and text frame styles are marked), and at
|
||||
/// the bottom Clear Overrides and Create New Style (⌥/Alt-click: the New Object Style dialog).
|
||||
pub fn object(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let lang = app.ui.language.clone();
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let names: Vec<String> = st.doc.styles.object.iter().map(|s| s.name.clone()).collect();
|
||||
let (default_graphic, default_text) = (st.doc.styles.default_graphic_frame.clone(), st.doc.styles.default_text_frame.clone());
|
||||
let current = object_style_of_selection(app);
|
||||
let selected = current.is_some();
|
||||
let shown = |n: &str, over: bool| format!("{}{}", crate::i18n::style_name(&lang, n), if over { "+" } else { "" });
|
||||
crate::rtl::label(ui, egui::RichText::new(current.as_ref().map_or_else(String::new, |(n, over)| shown(n, *over))).size(11.0).color(t.text_dim));
|
||||
for n in &names {
|
||||
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
|
||||
let is_current = current.as_ref().is_some_and(|(c, _)| c == n);
|
||||
if is_current {
|
||||
ui.painter().rect_filled(row, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(row, 0.0, t.hover);
|
||||
}
|
||||
let label = shown(n, is_current && current.as_ref().is_some_and(|c| c.1));
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, is_current, &label));
|
||||
crate::rtl::paint(ui.painter(), row.min + vec2(24.0, 11.0), egui::Align2::LEFT_CENTER, &label, egui::FontId::proportional(12.5), t.text);
|
||||
// The default styles of new graphic and text frames, as InDesign marks them.
|
||||
let mark = egui::Rect::from_center_size(egui::pos2(row.max.x - 14.0, row.center().y), vec2(11.0, 11.0));
|
||||
if *n == default_graphic {
|
||||
ui.painter().rect_stroke(mark, 0.0, egui::Stroke::new(1.0, t.icon), egui::StrokeKind::Inside);
|
||||
}
|
||||
if *n == default_text {
|
||||
ui.painter().rect_stroke(mark, 0.0, egui::Stroke::new(1.0, t.icon), egui::StrokeKind::Inside);
|
||||
ui.painter().text(mark.center(), egui::Align2::CENTER_CENTER, "T", egui::FontId::proportional(8.5), t.icon);
|
||||
}
|
||||
let editable = n != designcraft_doc::NO_OBJECT_STYLE;
|
||||
if resp.double_clicked() && editable {
|
||||
let _ = crate::object_style_dialog::open_edit(app, n);
|
||||
} else if resp.clicked() && selected {
|
||||
let _ = app.run("style.object.apply", json!({"name": n}));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
let label = crate::i18n::style_name(&lang, n);
|
||||
if editable && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Edit \"{name}\"…").replace("{name}", label))).clicked() {
|
||||
let _ = crate::object_style_dialog::open_edit(app, n);
|
||||
ui.close();
|
||||
}
|
||||
if ui
|
||||
.add_enabled(selected, egui::Button::new(crate::rtl::widget(ui, format!("{} \"{}\"", crate::i18n::tr(&lang, "Apply"), label))))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("style.object.apply", json!({"name": n}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if icons::button(ui, "style-new", 20.0, false, crate::i18n::tr(&lang, "Create New Style")).clicked() {
|
||||
if ui.input(|i| i.modifiers.alt) {
|
||||
crate::object_style_dialog::open_new(app);
|
||||
} else if selected {
|
||||
let _ = app.run("style.object.create", json!({"name": "Object Style 1", "fromSelection": true}));
|
||||
} else {
|
||||
crate::object_style_dialog::open_new(app);
|
||||
}
|
||||
}
|
||||
let over = current.as_ref().is_some_and(|c| c.1);
|
||||
if ui.add_enabled_ui(over, |ui| icons::button(ui, "style-clear", 20.0, false, crate::i18n::tr(&lang, "Clear Overrides"))).inner.clicked()
|
||||
&& over
|
||||
{
|
||||
let _ = app.run("style.object.clearOverrides", json!({}));
|
||||
}
|
||||
crate::menus::menu_button(ui, "☰", |ui| {
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "New Object Style…"))).clicked() {
|
||||
crate::object_style_dialog::open_new(app);
|
||||
ui.close();
|
||||
}
|
||||
if let Some((n, _)) = current.as_ref().filter(|(n, _)| n != designcraft_doc::NO_OBJECT_STYLE)
|
||||
&& ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Object Style Options…"))).clicked()
|
||||
{
|
||||
let _ = crate::object_style_dialog::open_edit(app, n);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// One style in the list: click applies (⌥ clears overrides), double-click edits,
|
||||
/// right-click: edit, apply, break link, move to group.
|
||||
fn style_row(
|
||||
@@ -215,6 +320,26 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A text frame whose paragraphs left its object style's paragraph style shows the override
|
||||
/// mark, and Clear Overrides puts them back.
|
||||
#[test]
|
||||
fn a_paragraph_style_change_is_an_object_style_override() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", serde_json::json!({})).unwrap();
|
||||
app.run("style.paragraph.create", serde_json::json!({"name": "Caption"})).unwrap();
|
||||
app.run("style.object.create", serde_json::json!({"name": "Captioned", "fromSelection": false, "paragraphStyle": "Caption"})).unwrap();
|
||||
let f = app.run("frame.create", serde_json::json!({"rect": [36, 36, 300, 200], "content": "text", "text": "One"})).unwrap();
|
||||
app.run("style.object.apply", serde_json::json!({"name": "Captioned", "ids": [f["id"]]})).unwrap();
|
||||
app.run("selection.set", serde_json::json!({"ids": [f["id"]]})).unwrap();
|
||||
assert_eq!(object_style_of_selection(&app), Some(("Captioned".into(), false)));
|
||||
app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 3})).unwrap();
|
||||
app.run("style.paragraph.apply", serde_json::json!({"name": designcraft_doc::BASIC_PARAGRAPH})).unwrap();
|
||||
app.run("selection.set", serde_json::json!({"ids": [f["id"]]})).unwrap();
|
||||
assert_eq!(object_style_of_selection(&app), Some(("Captioned".into(), true)), "the + mark");
|
||||
app.run("style.object.clearOverrides", serde_json::json!({})).unwrap();
|
||||
assert_eq!(object_style_of_selection(&app), Some(("Captioned".into(), false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_style_context_menu_redefines_its_format() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
@@ -243,6 +368,47 @@ mod tests {
|
||||
assert_eq!(h.state().session.doc().unwrap().doc.styles.para("Header").unwrap().chars.size, Some(24.0));
|
||||
}
|
||||
|
||||
/// The Object Styles panel: the selected frame's style is marked (`+` once overridden), a
|
||||
/// click applies a style, the eraser clears the overrides, + makes a style from the selection.
|
||||
#[test]
|
||||
fn object_styles_panel_applies_marks_overrides_and_creates() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let id = app.run("frame.create", json!({"rect": [36, 36, 136, 136], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("selection.set", json!({"ids": [id]})).unwrap();
|
||||
app.run("style.object.create", json!({"name": "Framed", "fromSelection": false, "stroke": {"swatch": "[Paper]", "weight": 3}})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(320.0, 260.0)).build_ui_state(
|
||||
|ui, app: &mut DesignApp| {
|
||||
let id = egui::Id::unique("test_fonts");
|
||||
if ui.data(|d| d.get_temp::<bool>(id)).is_none() {
|
||||
crate::theme::install_fonts(ui.ctx(), "");
|
||||
ui.data_mut(|d| d.insert_temp(id, true));
|
||||
return;
|
||||
}
|
||||
object(app, ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(4);
|
||||
h.get_by_label("Framed").click();
|
||||
h.run_steps(3);
|
||||
// The style on top and its row.
|
||||
assert_eq!(h.get_all_by_label("Framed").count(), 2);
|
||||
let style = |h: &Harness<DesignApp>| h.state().session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().clone();
|
||||
assert_eq!((style(&h).object_style.as_str(), style(&h).stroke.weight), ("Framed", 3.0));
|
||||
h.state_mut().run("object.stroke", json!({"weight": 1})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.get_all_by_label("Framed+").count(), 2);
|
||||
h.get_by_label("Clear Overrides").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(style(&h).stroke.weight, 3.0);
|
||||
assert_eq!(h.get_all_by_label("Framed").count(), 2);
|
||||
h.get_by_label("Create New Style").click();
|
||||
h.run_steps(3);
|
||||
let styles = h.state().session.active().unwrap().doc.styles.object.iter().map(|o| o.name.clone()).collect::<Vec<_>>();
|
||||
assert!(styles.contains(&"Object Style 1".to_string()), "{styles:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_color_override_is_marked_and_cleared_from_the_row_menu() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
|
||||
@@ -424,7 +424,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
||||
- Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML.
|
||||
- Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`.
|
||||
- Style groups: paragraph and character styles shown in group folders, Move to Group (new, existing, none); `style.group` renames every use (styles, stories, table cells, footnotes, object styles).
|
||||
- Object styles carry effects: `style.object.create` keeps the selection's effects, `style.object.apply` sets them, and `style.object.edit {name, dropShadow?, feather?, …, effects?: null}` changes them on the style and on every item whose effects still match it (items with their own effects keep them; `style.object.clearOverrides` returns them to the style). New frames take their default style's effects. Effect values that aren't finite numbers are refused and the others clamped, in these commands and in the `object.*` effect commands. IDML carries only the drop shadow and feather, for ObjectStyles (read per effects category flag, settings from the BasedOn chain) as for items; the other effects stay in `.designcraft` files. There is no Object Style Options dialog yet.
|
||||
- Object styles carry effects: `style.object.create` keeps the selection's effects, `style.object.apply` sets them, and `style.object.edit {name, dropShadow?, feather?, …, effects?: null}` changes them on the style and on every item whose effects still match it (items with their own effects keep them; `style.object.clearOverrides` returns them to the style). New frames take their default style's effects. Effect values that aren't finite numbers are refused and the others clamped, in these commands and in the `object.*` effect commands. IDML carries only the drop shadow and feather, for ObjectStyles (read per effects category flag, settings from the BasedOn chain) as for items; the other effects stay in `.designcraft` files. Object Style Options is a dialog (from the Object Styles panel), with General, Fill, Stroke, Stroke & Corner Options, Paragraph Styles (with Apply Next Style) and Text Frame General Options pages.
|
||||
- Scrubby zoom: drag the Zoom tool left or right to zoom continuously around the press point.
|
||||
- Spring-loaded Zoom: hold Cmd+Space (Ctrl+Space on Windows/Linux) for the Zoom cursor and click/scrubby zoom; Alt-click zooms out. Release returns to the previous tool without changing the document or undo history. Text-frame and text-field typing suppress the shortcut; Space remains Hand.
|
||||
- Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it.
|
||||
|
||||
Reference in new issue
Block a user