mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 04:36:47 -06:00
Merge IDML/text cluster
# Conflicts: # crates/ui-egui/src/i18n.rs # crates/ui-egui/src/i18n/ar.rs # crates/ui-egui/src/i18n/it.rs # crates/ui-egui/src/i18n/ja.rs # crates/ui-egui/src/i18n/pt_br.rs # crates/ui-egui/src/i18n/uk.rs
This commit is contained in:
@@ -147,6 +147,8 @@ Newest first.
|
||||
- **2026-10-14** · Layout › Margins and Columns and Layout › Create Guides are laid out as InDesign's: Margins in Top/Bottom and Inside/Outside pairs with a link button that makes all four the same, Rows and Columns side by side, Fit Guides to Margins or Page, and a Preview checkbox that shows the change on the canvas before OK. A measurement field works out arithmetic when you leave it (`0.4375 in*2` becomes `0.875 in`). No readiness percentage change.
|
||||
- **2026-10-14** · The Pathfinder panel is laid out as InDesign's: Paths (Join, Open, Close, Reverse), Pathfinder, Convert Shape and Convert Point, each a row of icon buttons. Join Path closes an open path or joins two at their nearest ends (`path.join`), and Convert Point makes the selected points plain, corner, smooth or symmetrical (`path.convertPoints`). No readiness percentage change.
|
||||
- **2026-10-12** · Linked text files (#597): placed Word, RTF and text files can stay linked; the Links panel shows them OK / Modified / Missing, and Update imports the file again into the same frames with the saved style map (the Links panel asks before replacing edits made in the layout). No readiness percentage change.
|
||||
- **2026-10-15** · IDML: the CJK leading model InDesign writes on [No paragraph style] of every document (Aki Below) only applies when the document uses a Japanese composer, as in InDesign; a Western document's paragraphs after a size change no longer sit lower than in InDesign (Montilla Design's Flex Layout file: its captions came out overset). No readiness percentage change.
|
||||
- **2026-10-14** · Type › Fill with Placeholder Text fills a frame with paragraphs of a few sentences, as InDesign does, so one paragraph can be styled on its own (it was one long paragraph, and the filler ran sentences together, "interit.Ovid"). No readiness percentage change.
|
||||
- **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change.
|
||||
- **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change.
|
||||
- **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change.
|
||||
@@ -173,6 +175,8 @@ Newest first.
|
||||
solid cyan; fractions take the colour's own `{model: "cmyk", …}` form (#910). No readiness percentage change.
|
||||
- **2026-10-14** · The Tools panel follows InDesign's: Gradient Swatch and Gradient Feather are tools of their own; in two columns Content Collector and Placer, Apply Color / Gradient / None and Normal / Preview each get a button; View Options holds Frame Edges, Rulers, Guides, Smart Guides, Baseline Grid and Hidden Characters; Apply Color (,) and Apply Gradient (.) reuse the last colour and gradient. No readiness percentage change.
|
||||
- **2026-10-13** · Create Outlines makes one compound path per line (and per text colour within a line), grouped, as InDesign does: Ungroup then leaves each line on its own to be spaced and made into one compound path again. A single line in one colour stays a single compound path. No readiness percentage change.
|
||||
- **2026-10-13** · Gradient swatches work on strokes: drawn across the path's box on screen and in PDF (they drew nothing), set with `object.gradient {target: stroke}` and in the Gradient panel when the Stroke box is in front; the stroke's gradient vector moves with the shape and round-trips through IDML. No readiness percentage change.
|
||||
- **2026-10-13** · Text takes gradient swatches: a gradient fill or stroke on characters spans the text frame's box (on a path: the path's), on screen and in PDF. No readiness percentage change.
|
||||
- **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load
|
||||
the text cursor, then click a frame to thread it, click empty space for a column-wide frame or
|
||||
drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the
|
||||
|
||||
+69
-1
@@ -62,7 +62,7 @@ macro_rules! attr_set {
|
||||
return Ok(());
|
||||
}
|
||||
)*
|
||||
Err(format!("unknown attribute `{key}`"))
|
||||
Err(unknown_attribute(key, &[ $( stringify!($field), )* ]))
|
||||
}
|
||||
/// Fields where `self` differs from `base`'s resolved value (used for "clear overrides").
|
||||
pub fn diff_from(&self, base: &$props) -> $attrs {
|
||||
@@ -104,6 +104,57 @@ fn attr_key(snake: &'static str) -> &'static str {
|
||||
snake
|
||||
}
|
||||
|
||||
/// `snake_case` → `camelCase`, as commands name attributes.
|
||||
fn camel(snake: &str) -> String {
|
||||
let mut out = String::with_capacity(snake.len());
|
||||
let mut up = false;
|
||||
for c in snake.chars() {
|
||||
if c == '_' {
|
||||
up = true;
|
||||
} else if up {
|
||||
out.push(c.to_ascii_uppercase());
|
||||
up = false;
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Edits (insert, delete, replace) that turn `a` into `b`, ignoring case and underscores.
|
||||
fn edit_distance(a: &str, b: &str) -> usize {
|
||||
let norm = |s: &str| s.chars().filter(|c| *c != '_').map(|c| c.to_ascii_lowercase()).collect::<Vec<_>>();
|
||||
let (a, b) = (norm(a), norm(b));
|
||||
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
||||
for (i, ca) in a.iter().enumerate() {
|
||||
let mut row = vec![i + 1];
|
||||
for (j, cb) in b.iter().enumerate() {
|
||||
let d = (prev[j] + usize::from(ca != cb)).min(prev[j + 1] + 1).min(row[j] + 1);
|
||||
row.push(d);
|
||||
}
|
||||
prev = row;
|
||||
}
|
||||
prev.last().copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The error for an attribute `key` that isn't one of `fields` (snake_case): the nearest one
|
||||
/// (a typo, or a name it starts or ends: `font` → `fontFamily`), or else all of them.
|
||||
fn unknown_attribute(key: &str, fields: &[&str]) -> String {
|
||||
let k = key.to_ascii_lowercase().replace('_', "");
|
||||
let flat = |f: &str| f.replace('_', "");
|
||||
let near = fields
|
||||
.iter()
|
||||
.map(|f| (edit_distance(key, f), *f))
|
||||
.filter(|(d, _)| *d <= (k.len() / 3).max(1))
|
||||
.min()
|
||||
.map(|(_, f)| f)
|
||||
.or_else(|| fields.iter().copied().find(|f| k.len() >= 3 && (flat(f).starts_with(&k) || flat(f).ends_with(&k))));
|
||||
match near {
|
||||
Some(f) => format!("unknown attribute `{key}`; did you mean `{}`?", camel(f)),
|
||||
None => format!("unknown attribute `{key}`; the attributes are: {}", fields.iter().map(|f| camel(f)).collect::<Vec<_>>().join(", ")),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- value types ----------
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -985,6 +1036,23 @@ impl ParaProps {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #777: an unknown attribute names the nearest one, or lists them all.
|
||||
#[test]
|
||||
fn unknown_attributes_suggest_the_right_name() {
|
||||
let mut c = CharAttrs::default();
|
||||
assert_eq!(c.set_json("font", &serde_json::json!("X")).unwrap_err(), "unknown attribute `font`; did you mean `fontFamily`?");
|
||||
assert_eq!(c.set_json("fontFamly", &serde_json::json!("X")).unwrap_err(), "unknown attribute `fontFamly`; did you mean `fontFamily`?");
|
||||
let all = c.set_json("zzzzzz", &serde_json::json!(1)).unwrap_err();
|
||||
assert!(
|
||||
all.starts_with("unknown attribute `zzzzzz`; the attributes are: ") && all.contains("fontFamily") && all.contains("fontStyle"),
|
||||
"{all}"
|
||||
);
|
||||
assert!(c.set_json("font_family", &serde_json::json!("X")).is_ok() && c.set_json("fontFamily", &serde_json::json!("X")).is_ok());
|
||||
let mut p = ParaAttrs::default();
|
||||
assert_eq!(p.set_json("spaceBefor", &serde_json::json!(3)).unwrap_err(), "unknown attribute `spaceBefor`; did you mean `spaceBefore`?");
|
||||
assert_eq!(camel("first_line_indent"), "firstLineIndent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_partial_rule_keeps_the_other_fields() {
|
||||
let current = ParaProps {
|
||||
|
||||
@@ -54,6 +54,21 @@ impl Fill {
|
||||
}
|
||||
}
|
||||
|
||||
/// A linear gradient's axis on an object whose bounds (in its own space) are `b`: the
|
||||
/// Gradient Swatch tool's vector, or the box's extent along the gradient's angle through its
|
||||
/// centre. A box with no extent along that angle (a straight line, across it) takes its longer
|
||||
/// side.
|
||||
pub fn linear_axis(&self, b: Rect) -> (Point, Point) {
|
||||
if let Some([x0, y0, x1, y1]) = self.gradient_vector {
|
||||
return (Point::new(x0, y0), Point::new(x1, y1));
|
||||
}
|
||||
let a = self.gradient_angle.unwrap_or(0.0).to_radians();
|
||||
let half = (b.width() * a.cos().abs() + b.height() * a.sin().abs()) / 2.0;
|
||||
let half = if half > 1e-9 { half } else { b.width().max(b.height()).max(1e-3) / 2.0 };
|
||||
let d = designcraft_geom::Vec2::new(a.cos(), -a.sin()) * half;
|
||||
(b.center() - d, b.center() + d)
|
||||
}
|
||||
|
||||
pub fn none() -> Self {
|
||||
Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, gradient_angle: None, gradient_vector: None, overprint: false }
|
||||
}
|
||||
@@ -246,6 +261,12 @@ pub struct Stroke {
|
||||
/// Overprint Gap (Attributes panel).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub gap_overprint: bool,
|
||||
/// A gradient swatch's angle on the stroke, as [`Fill::gradient_angle`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gradient_angle: Option<f64>,
|
||||
/// The Gradient Swatch tool's vector on the stroke, as [`Fill::gradient_vector`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gradient_vector: Option<[f64; 4]>,
|
||||
}
|
||||
|
||||
impl Default for Stroke {
|
||||
@@ -266,11 +287,25 @@ impl Default for Stroke {
|
||||
gap_tint: 1.0,
|
||||
overprint: false,
|
||||
gap_overprint: false,
|
||||
gradient_angle: None,
|
||||
gradient_vector: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Stroke {
|
||||
/// The stroke's paint (swatch, tint and gradient geometry) as a fill, to paint it the same way:
|
||||
/// a gradient swatch on a stroke runs across the object's box as on its fill.
|
||||
pub fn as_fill(&self) -> Fill {
|
||||
Fill {
|
||||
swatch: self.swatch.clone(),
|
||||
tint: self.tint,
|
||||
gradient_angle: self.gradient_angle,
|
||||
gradient_vector: self.gradient_vector,
|
||||
overprint: self.overprint,
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the painted stroke can reach beyond the path (arrowheads reach further).
|
||||
pub fn extent(&self) -> f64 {
|
||||
if self.start == Arrowhead::None && self.end == Arrowhead::None { self.weight } else { self.weight.max(0.5) * 5.5 }
|
||||
|
||||
@@ -1165,12 +1165,15 @@ fn union_bounds(d: &Document, ids: &[ItemId]) -> Rect {
|
||||
ids.iter().filter_map(|i| d.item(*i)).map(Item::bounds).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO)
|
||||
}
|
||||
|
||||
/// Transform an item's geometry in its own space: the path and the gradient vector with it.
|
||||
/// Transform an item's geometry in its own space: the path and the gradient vectors (fill and
|
||||
/// stroke) with it.
|
||||
pub(crate) fn bake(it: &mut Item, inner: Affine) {
|
||||
it.path.transform(inner);
|
||||
if let Some([x0, y0, x1, y1]) = it.fill.gradient_vector {
|
||||
let (a, b) = (inner * Point::new(x0, y0), inner * Point::new(x1, y1));
|
||||
it.fill.gradient_vector = Some([a.x, a.y, b.x, b.y]);
|
||||
for v in [&mut it.fill.gradient_vector, &mut it.stroke.gradient_vector] {
|
||||
if let Some([x0, y0, x1, y1]) = *v {
|
||||
let (a, b) = (inner * Point::new(x0, y0), inner * Point::new(x1, y1));
|
||||
*v = Some([a.x, a.y, b.x, b.y]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+172
-22
@@ -86,7 +86,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Paragraph Style…",
|
||||
[],
|
||||
None,
|
||||
"{name, basedOn?, nextStyle?, para?: {…}, chars?: {…}, fromSelection?: bool}",
|
||||
"{name, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, firstLineIndent, spaceBefore, spaceAfter, …}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}, fromSelection?: bool}",
|
||||
has_doc,
|
||||
create_para
|
||||
),
|
||||
@@ -95,7 +95,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paragraph Style Options…",
|
||||
[],
|
||||
None,
|
||||
"{name, rename?, basedOn?, nextStyle?, para?: {… ruleAbove?/ruleBelow?: only the named rule fields change}, chars?: {…}}",
|
||||
"{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, …; ruleAbove?/ruleBelow?: only the named rule fields change}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}",
|
||||
has_doc,
|
||||
edit_para
|
||||
),
|
||||
@@ -105,7 +105,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Character Style…",
|
||||
[],
|
||||
None,
|
||||
"{name, basedOn?: style | \"[None]\", chars?: {…}, fromSelection?: bool (default false)} — if true, capture the first selected character's style/local overrides (caret: typing format), then overlay chars (null unsets); only explicit fill names are validated; paragraph formatting stays inherited; no text target: chars only → {name} (made unique)",
|
||||
"{name, basedOn?: style | \"[None]\", chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}, fromSelection?: bool (default false)} — if true, capture the first selected character's style/local overrides (caret: typing format), then overlay chars (null unsets); only explicit fill names are validated; paragraph formatting stays inherited; no text target: chars only → {name} (made unique)",
|
||||
has_doc,
|
||||
create_char
|
||||
),
|
||||
@@ -114,7 +114,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character Style Options…",
|
||||
[],
|
||||
None,
|
||||
"{name, rename?, basedOn?: style | \"[None]\" | null, chars?: {attr: value | null (no longer set)}} — renaming updates every use",
|
||||
"{name, rename?, basedOn?: style | \"[None]\" | null, chars?: {character attribute (as type.char names them: fontFamily, fontStyle, size, …): value | null (no longer set)}} — renaming updates every use",
|
||||
has_doc,
|
||||
edit_char
|
||||
),
|
||||
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gradient",
|
||||
[],
|
||||
None,
|
||||
"{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b]|{model, …}(0..1), opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), ids?} → {swatch, kind, stops} — edits the fill's gradient (an unnamed gradient unless it equals a gradient swatch)",
|
||||
"{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b]|{model, …}(0..1), opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), target?: fill|stroke, ids?} → {swatch, kind, stops} — edits the fill's (or the stroke's) gradient (an unnamed gradient unless it equals a gradient swatch)",
|
||||
super::has_selection,
|
||||
apply_gradient
|
||||
),
|
||||
@@ -380,12 +380,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
fn attrs<T: Default>(v: Option<&Value>, set: impl Fn(&mut T, &str, &Value) -> std::result::Result<(), String>) -> Result<T> {
|
||||
/// The `group` (`chars`, `para`) object of command `cmd`'s params as attributes; a bad attribute
|
||||
/// is that command's parameter error, naming the group.
|
||||
fn attrs<T: Default>(cmd: &str, group: &str, p: &Value, set: impl Fn(&mut T, &str, &Value) -> std::result::Result<(), String>) -> Result<T> {
|
||||
let mut a = T::default();
|
||||
if let Some(o) = v.and_then(Value::as_object) {
|
||||
for (k, v) in o {
|
||||
set(&mut a, k, v).map_err(|e| bad("style", e))?;
|
||||
match p.get(group) {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::Object(o)) => {
|
||||
for (k, v) in o {
|
||||
set(&mut a, k, v).map_err(|e| bad(cmd, format!("{group}: {e}")))?;
|
||||
}
|
||||
}
|
||||
Some(_) => return Err(bad(cmd, format!("{group} must be an object of attributes"))),
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
@@ -436,8 +442,8 @@ fn apply_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn create_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string();
|
||||
let mut para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json(k, v))?;
|
||||
let mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
|
||||
let mut para: ParaAttrs = attrs("style.paragraph.create", "para", p, |a: &mut ParaAttrs, k, v| a.set_json(k, v))?;
|
||||
let mut chars: CharAttrs = attrs("style.paragraph.create", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
|
||||
// Validate caller-supplied fills, not selection fills retained after a swatch is deleted.
|
||||
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?;
|
||||
if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false)
|
||||
@@ -480,7 +486,7 @@ fn CharProps_to_attrs(p: &designcraft_doc::CharProps) -> CharAttrs {
|
||||
|
||||
fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string();
|
||||
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
|
||||
let chars: CharAttrs = attrs("style.paragraph.edit", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
|
||||
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?;
|
||||
let rename = str_param(p, "rename").map(str::to_string);
|
||||
let based = p.get("basedOn").cloned();
|
||||
@@ -492,7 +498,7 @@ fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// A rule object changes only the rule fields it names, over the style's resolved rule.
|
||||
let (current, _) = d.styles.resolve_para_style(&name);
|
||||
let para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, ¤t))?;
|
||||
let para: ParaAttrs = attrs("style.paragraph.edit", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, ¤t))?;
|
||||
let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?;
|
||||
st.para.merge(¶);
|
||||
st.chars.merge(&chars);
|
||||
@@ -632,7 +638,7 @@ fn delete_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn create_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = str_param(p, "name").unwrap_or("Character Style 1").to_string();
|
||||
let mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
|
||||
let mut chars: CharAttrs = attrs("style.character.create", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
|
||||
// Validate caller-supplied fills; captured formatting may retain a deleted swatch name.
|
||||
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.create")?;
|
||||
if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false)
|
||||
@@ -647,7 +653,7 @@ fn create_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Replay the explicit fields so null clears a captured value rather than looking absent.
|
||||
if let Some(overrides) = p.get("chars").and_then(Value::as_object) {
|
||||
for (key, value) in overrides {
|
||||
captured.set_json(key, value).map_err(|e| bad("style", e))?;
|
||||
captured.set_json(key, value).map_err(|e| bad("style.character.create", format!("chars: {e}")))?;
|
||||
}
|
||||
}
|
||||
chars = captured;
|
||||
@@ -671,7 +677,7 @@ fn edit_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if name == designcraft_doc::NO_CHAR_STYLE {
|
||||
return Err(bad("style.character.edit", "[None] can't be edited"));
|
||||
}
|
||||
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
|
||||
let chars: CharAttrs = attrs("style.character.edit", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
|
||||
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.edit")?;
|
||||
// `null` unsets an attribute (the style no longer sets it).
|
||||
let unset: Vec<String> = p
|
||||
@@ -926,13 +932,18 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false);
|
||||
let angle = p.get("angle").and_then(Value::as_f64);
|
||||
let stroke = match str_param(p, "target") {
|
||||
None | Some("fill") => false,
|
||||
Some("stroke") => true,
|
||||
Some(t) => return Err(bad("object.gradient", format!("unknown target {t}"))),
|
||||
};
|
||||
let (from, to) = (super::point_param(p, "from"), super::point_param(p, "to"));
|
||||
let ids = super::targets(s, p)?;
|
||||
s.edit(|d, _| {
|
||||
let mut out = Value::Null;
|
||||
for id in &ids {
|
||||
let Some(it) = d.item(*id) else { continue };
|
||||
let cur_name = it.fill.swatch.clone();
|
||||
let cur_name = if stroke { it.stroke.swatch.clone() } else { it.fill.swatch.clone() };
|
||||
let cur = designcraft_color::swatch::resolve_gradient(&d.swatches, &cur_name).cloned();
|
||||
let mut g = cur.clone().unwrap_or_default();
|
||||
if let Some(k) = kind {
|
||||
@@ -964,16 +975,22 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let inv = d.find(*id).map(|l| d.parent_xf(&l)).zip(d.item(*id).map(|i| i.xf)).map(|(pxf, xf)| (pxf * xf).inverse());
|
||||
let Some(it) = d.item_mut(*id) else { continue };
|
||||
it.fill.swatch = name.clone();
|
||||
it.fill.tint = 1.0;
|
||||
// The fill's or the stroke's paint: swatch, tint, angle, vector.
|
||||
let (swatch, tint, g_angle, g_vector) = if stroke {
|
||||
(&mut it.stroke.swatch, &mut it.stroke.tint, &mut it.stroke.gradient_angle, &mut it.stroke.gradient_vector)
|
||||
} else {
|
||||
(&mut it.fill.swatch, &mut it.fill.tint, &mut it.fill.gradient_angle, &mut it.fill.gradient_vector)
|
||||
};
|
||||
*swatch = name.clone();
|
||||
*tint = 1.0;
|
||||
if let Some(a) = angle {
|
||||
it.fill.gradient_angle = Some(a);
|
||||
it.fill.gradient_vector = None;
|
||||
*g_angle = Some(a);
|
||||
*g_vector = None;
|
||||
}
|
||||
if let (Some(a), Some(b), Some(inv)) = (from, to, inv) {
|
||||
let (a, b) = (inv * a, inv * b);
|
||||
if (b - a).hypot() > 1e-6 {
|
||||
it.fill.gradient_vector = Some([a.x, a.y, b.x, b.y]);
|
||||
*g_vector = Some([a.x, a.y, b.x, b.y]);
|
||||
}
|
||||
}
|
||||
if out.is_null() {
|
||||
@@ -1262,6 +1279,48 @@ fn rename_in(it: &mut designcraft_doc::Item, from: &str, to: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// #777: the params descriptions name the attributes, and a wrong one is the command's error.
|
||||
#[cfg(test)]
|
||||
mod attribute_error_tests {
|
||||
use super::*;
|
||||
use crate::cmd::find_command;
|
||||
|
||||
#[test]
|
||||
fn a_wrong_attribute_names_the_command_and_the_right_attribute() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let err = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap_err().to_string();
|
||||
assert_eq!(
|
||||
err(&mut s, "style.paragraph.create", json!({"name": "H", "chars": {"font": "Source Sans 3"}})),
|
||||
"invalid parameters for `style.paragraph.create`: chars: unknown attribute `font`; did you mean `fontFamily`?"
|
||||
);
|
||||
assert_eq!(
|
||||
err(&mut s, "style.paragraph.create", json!({"name": "H", "para": {"spaceBefor": 6}})),
|
||||
"invalid parameters for `style.paragraph.create`: para: unknown attribute `spaceBefor`; did you mean `spaceBefore`?"
|
||||
);
|
||||
assert!(
|
||||
err(&mut s, "style.character.create", json!({"name": "C", "chars": {"font": "X"}}))
|
||||
.starts_with("invalid parameters for `style.character.create`: chars:")
|
||||
);
|
||||
assert!(err(&mut s, "style.paragraph.create", json!({"name": "H", "chars": "bold"})).contains("chars must be an object of attributes"));
|
||||
s.execute("style.paragraph.create", &json!({"name": "H", "chars": {"fontFamily": "Source Sans 3", "size": 14}})).unwrap();
|
||||
assert!(
|
||||
err(&mut s, "style.paragraph.edit", json!({"name": "H", "chars": {"font": "X"}}))
|
||||
.starts_with("invalid parameters for `style.paragraph.edit`: chars:")
|
||||
);
|
||||
s.execute("style.character.create", &json!({"name": "C", "chars": {"fontStyle": "Bold"}})).unwrap();
|
||||
assert!(
|
||||
err(&mut s, "style.character.edit", json!({"name": "C", "chars": {"font": "X"}}))
|
||||
.starts_with("invalid parameters for `style.character.edit`: chars:")
|
||||
);
|
||||
for id in ["style.paragraph.create", "style.paragraph.edit", "style.character.create", "style.character.edit"] {
|
||||
let doc = find_command(id).unwrap().params;
|
||||
assert!(doc.contains("as type.char names them: fontFamily"), "{id}: {doc}");
|
||||
}
|
||||
assert!(find_command("style.paragraph.create").unwrap().params.contains("as type.para names them: align"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod color_tests {
|
||||
use super::*;
|
||||
@@ -1997,6 +2056,97 @@ mod radial_gradient_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gradient_stroke_tests {
|
||||
use serde_json::json;
|
||||
|
||||
/// Grey along a 20 pt line from (20, 60) to (180, 60): near its start, middle and end.
|
||||
fn along(img: &designcraft_render::Rendered) -> (u8, u8, u8) {
|
||||
(img.pixel(26, 60)[0], img.pixel(100, 60)[0], img.pixel(174, 60)[0])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_on_a_stroke_runs_across_the_path_on_screen_and_in_pdf() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap();
|
||||
let id = s.execute("line.create", &json!({"a": [20, 60], "b": [180, 60]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.stroke", &json!({"ids": [id], "weight": 20})).unwrap();
|
||||
let r = s
|
||||
.execute(
|
||||
"object.gradient",
|
||||
&json!({"ids": [id], "target": "stroke", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}),
|
||||
)
|
||||
.unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let it = d.item(designcraft_doc::ItemId(id)).unwrap();
|
||||
assert_eq!(it.stroke.swatch, r["swatch"].as_str().unwrap());
|
||||
assert_eq!(it.fill.swatch, designcraft_color::swatch::NONE, "the fill is left alone");
|
||||
let mut rr = designcraft_render::Renderer::new();
|
||||
let (a, m, b) = along(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap());
|
||||
assert!(a < 40 && (90..170).contains(&m) && b > 215, "screen: {a} {m} {b}");
|
||||
// The PDF, placed back and drawn: the same.
|
||||
let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap();
|
||||
let mut p = crate::Session::new();
|
||||
p.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap();
|
||||
p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "stroke.pdf", "x": 0, "y": 0, "width": 200}))
|
||||
.unwrap();
|
||||
let pd = p.doc().unwrap().doc.clone();
|
||||
let (a, m, b) = along(&rr.render_page(&pd, &p.cache, 0, 1.0, false, &Default::default()).unwrap());
|
||||
assert!(a < 40 && (90..170).contains(&m) && b > 215, "pdf: {a} {m} {b}");
|
||||
// The Gradient Swatch tool's drag on the stroke: reversed by dragging right to left.
|
||||
s.execute("object.gradient", &json!({"ids": [id], "target": "stroke", "from": [180, 60], "to": [20, 60]})).unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let (a, _, b) = along(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap());
|
||||
assert!(a > 215 && b < 40, "dragged: {a} {b}");
|
||||
assert!(s.execute("object.gradient", &json!({"ids": [id], "target": "gap"})).is_err());
|
||||
}
|
||||
|
||||
/// A 20 pt horizontal line with a gradient on its stroke, drawn on screen and from its PDF.
|
||||
fn line_with_stroke_gradient(g: serde_json::Value) -> (designcraft_render::Rendered, designcraft_render::Rendered) {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap();
|
||||
let id = s.execute("line.create", &json!({"a": [20, 60], "b": [180, 60]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.stroke", &json!({"ids": [id], "weight": 20})).unwrap();
|
||||
let mut g = g;
|
||||
g["ids"] = json!([id]);
|
||||
g["target"] = json!("stroke");
|
||||
s.execute("object.gradient", &g).unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let mut rr = designcraft_render::Renderer::new();
|
||||
let screen = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap();
|
||||
let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap();
|
||||
let mut p = crate::Session::new();
|
||||
p.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap();
|
||||
p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "line.pdf", "x": 0, "y": 0, "width": 200})).unwrap();
|
||||
let pd = p.doc().unwrap().doc.clone();
|
||||
let placed = rr.render_page(&pd, &p.cache, 0, 1.0, false, &Default::default()).unwrap();
|
||||
(screen, placed)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_radial_gradient_on_a_straight_lines_stroke_shows_all_its_stops() {
|
||||
// The line's box has no height: the gradient is a circle across the line's length.
|
||||
let (screen, pdf) = line_with_stroke_gradient(
|
||||
json!({"kind": "radial", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}),
|
||||
);
|
||||
for (what, img) in [("screen", &screen), ("pdf", &pdf)] {
|
||||
let (a, m, b) = along(img);
|
||||
assert!(m < 40 && a > 180 && b > 180, "{what}: {a} {m} {b}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linear_gradient_across_a_straight_line_paints_its_stroke_alike_on_screen_and_in_pdf() {
|
||||
let (screen, pdf) =
|
||||
line_with_stroke_gradient(json!({"angle": 90, "stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]}));
|
||||
let (s, p) = (screen.pixel(100, 60), pdf.pixel(100, 60));
|
||||
// The line's box has no height: the gradient runs across the line's length, so the stroke
|
||||
// shows its middle.
|
||||
assert!(s[1] < 60 && s[0] > 80 && s[2] > 80, "screen: the stroke is painted, {s:?}");
|
||||
assert!((0..3).all(|i| s[i].abs_diff(p[i]) < 40), "screen {s:?}, pdf {p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "text_color_tests.rs"]
|
||||
mod text_color_tests;
|
||||
|
||||
@@ -55,6 +55,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ok()
|
||||
}),
|
||||
cmd!("text.insert", "Type", [], None, "{text, raw?: bool (no typographer's quotes)} — replaces the selected text", has_text, insert),
|
||||
// Neither journalled nor undoable: what is composed is a preview until it's typed.
|
||||
CommandSpec {
|
||||
id: "text.compose",
|
||||
label: "Compose Text",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "{text} — input-method text being composed (a dead key's accent), shown at the caret in the text's format until text.insert types the result; \"\" drops it",
|
||||
enabled: has_doc,
|
||||
run: compose_text,
|
||||
journal: false,
|
||||
undoable: false,
|
||||
},
|
||||
cmd!("text.delete", "Delete Text", [], None, "{forward?: bool, word?: bool}", has_text, delete),
|
||||
cmd!(noundo "text.move", "Move Caret", [], None, "{dir: left|right|up|down|lineStart|lineEnd|storyStart|storyEnd, extend?, word?}", has_text, move_caret),
|
||||
cmd!(noundo "text.exitToFrame", "Select Frame", [], None, "{}", has_doc, |s, _| {
|
||||
@@ -508,6 +520,30 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `text.compose`: show input-method text at the caret, typed in the format the next
|
||||
/// character would get, without making it part of the story. Replaces what was composed before.
|
||||
fn compose_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = super::text_param(p, "text");
|
||||
s.end_composition();
|
||||
if text.is_empty() {
|
||||
return ok();
|
||||
}
|
||||
has_text(s).map_err(|e| bad("text.compose", &e))?;
|
||||
// The preview types the text; the format chosen at the caret is still for the real typing.
|
||||
let typing = s.typing_format.clone();
|
||||
s.begin_interaction("Type")?;
|
||||
let r = s.preview("text.insert", json!({"text": text, "raw": true}));
|
||||
s.typing_format = typing;
|
||||
if let Err(e) = r {
|
||||
s.cancel_interaction();
|
||||
return Err(e);
|
||||
}
|
||||
let uid = s.doc()?.uid;
|
||||
s.composition = Some((uid, text.len()));
|
||||
let pos = s.doc()?.selection.text.map_or(0, |t| t.focus);
|
||||
Ok(json!({"range": [pos.saturating_sub(text.len()), pos]}))
|
||||
}
|
||||
|
||||
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = super::text_param(p, "text");
|
||||
// Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`).
|
||||
@@ -1551,20 +1587,30 @@ fn fill_placeholder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
story_of(s, p).ok_or_else(|| bad("type.fillWithPlaceholder", "select a text frame"))?
|
||||
}
|
||||
};
|
||||
// Fill until overset: repeat the filler sentences.
|
||||
// Fill until overset with paragraphs of two to five sentences, as InDesign's placeholder text
|
||||
// comes (so a paragraph can be styled on its own).
|
||||
const SENTENCES_PER_PARAGRAPH: [usize; 7] = [2, 2, 3, 3, 5, 2, 4];
|
||||
let mut text = String::new();
|
||||
let sentences: Vec<&str> = PLACEHOLDER.split_inclusive(". ").collect();
|
||||
let sentences: Vec<&str> = PLACEHOLDER.split_inclusive(". ").map(str::trim_end).collect();
|
||||
let doc = s.doc()?.doc.clone();
|
||||
let mut d2 = (*doc).clone();
|
||||
let (mut paragraph, mut in_paragraph) = (0, 0);
|
||||
for i in 0..400 {
|
||||
text.push_str(sentences[i % sentences.len()]);
|
||||
in_paragraph += 1;
|
||||
if in_paragraph >= SENTENCES_PER_PARAGRAPH[paragraph % SENTENCES_PER_PARAGRAPH.len()] {
|
||||
text.push('\n');
|
||||
(paragraph, in_paragraph) = (paragraph + 1, 0);
|
||||
} else {
|
||||
text.push(' ');
|
||||
}
|
||||
if i % 3 == 2 {
|
||||
d2.set_story_text(sid, &text)?;
|
||||
let cs = compose::compose_story(&d2, sid, &compose::ComposeOptions::default());
|
||||
if cs.is_overset() {
|
||||
// Trim to the last sentence that fits.
|
||||
let cut = cs.overset_at.unwrap_or(text.len());
|
||||
let keep = text[..cut.min(text.len())].rfind(". ").map(|k| k + 1).unwrap_or(cut);
|
||||
let cut = cs.overset_at.unwrap_or(text.len()).min(text.len());
|
||||
let keep = text.get(..cut).and_then(|t| t.rfind('.')).map(|k| k + 1).unwrap_or(cut);
|
||||
text.truncate(keep);
|
||||
break;
|
||||
}
|
||||
@@ -2783,3 +2829,219 @@ mod open_type_features_tests {
|
||||
assert_eq!(feature(&v, "lnum")["applied"], "none", "{v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gradient_text_tests {
|
||||
use serde_json::json;
|
||||
|
||||
/// Mean red and blue of the ink (pixels whose green is low: red → blue has none) in columns
|
||||
/// `x0..x1`.
|
||||
fn ink(img: &designcraft_render::Rendered, x0: u32, x1: u32) -> (f64, f64) {
|
||||
let (mut r, mut b, mut n) = (0.0, 0.0, 0.0);
|
||||
for x in x0..x1 {
|
||||
for y in 0..img.height {
|
||||
let p = img.pixel(x, y);
|
||||
if p[1] < 100 {
|
||||
(r, b, n) = (r + p[0] as f64, b + p[2] as f64, n + 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(n > 50.0, "no ink in {x0}..{x1}");
|
||||
(r / n, b / n)
|
||||
}
|
||||
|
||||
/// Red ink at the frame's left, blue at its right.
|
||||
fn check(img: &designcraft_render::Rendered, what: &str) {
|
||||
let (lr, lb) = ink(img, 0, 60);
|
||||
let (rr, rb) = ink(img, 200, 300);
|
||||
assert!(lr > lb + 80.0, "{what}: the left ink is red ({lr:.0}, {lb:.0})");
|
||||
assert!(rb > rr + 80.0, "{what}: the right ink is blue ({rr:.0}, {rb:.0})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_on_text_spans_its_frame_on_screen_and_in_pdf() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false, "margins": 0})).unwrap();
|
||||
// A gradient swatch (an off-page object's unnamed one), red → blue.
|
||||
let shape = s.execute("frame.create", &json!({"rect": [-60, 0, -50, 10], "content": "unassigned"})).unwrap()["id"].clone();
|
||||
s.execute("object.gradient", &json!({"ids": [shape], "stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]}))
|
||||
.unwrap();
|
||||
let id = designcraft_doc::ItemId(shape.as_u64().unwrap());
|
||||
let swatch = s.doc().unwrap().doc.item(id).unwrap().fill.swatch.clone();
|
||||
let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 120], "content": "text", "text": "IIIIIIIIIIIIIIIIIIIIIIIIIIIIII"})).unwrap();
|
||||
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 30})).unwrap();
|
||||
s.execute("type.char", &json!({"size": 60, "tracking": 0, "fill": swatch})).unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let mut rr = designcraft_render::Renderer::new();
|
||||
check(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(), "screen");
|
||||
// The PDF, placed back and drawn: the same gradient.
|
||||
let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap();
|
||||
let mut p = crate::Session::new();
|
||||
p.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false})).unwrap();
|
||||
p.execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name": "text.pdf", "x": 0, "y": 0, "width": 300}))
|
||||
.unwrap();
|
||||
let d = p.doc().unwrap().doc.clone();
|
||||
check(&rr.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap(), "pdf");
|
||||
}
|
||||
|
||||
/// The columns the red → blue ink spans (black table edges left out).
|
||||
fn ink_span(img: &designcraft_render::Rendered) -> (u32, u32) {
|
||||
let cols: Vec<u32> = (0..img.width)
|
||||
.filter(|&x| {
|
||||
(0..img.height).any(|y| {
|
||||
let p = img.pixel(x, y);
|
||||
p[1] < 100 && p[0].max(p[2]) > 100
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
(*cols.first().unwrap(), *cols.last().unwrap())
|
||||
}
|
||||
|
||||
/// Mean red and blue of the red → blue ink in columns `x0..x1`.
|
||||
fn coloured_ink(img: &designcraft_render::Rendered, x0: u32, x1: u32) -> (f64, f64) {
|
||||
let (mut r, mut b, mut n) = (0.0, 0.0, 0.0);
|
||||
for x in x0..x1 {
|
||||
for y in 0..img.height {
|
||||
let p = img.pixel(x, y);
|
||||
if p[1] < 100 && p[0].max(p[2]) > 100 {
|
||||
(r, b, n) = (r + p[0] as f64, b + p[2] as f64, n + 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(n > 20.0, "no ink in {x0}..{x1}");
|
||||
(r / n, b / n)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_on_table_cell_text_spans_the_cells_text_in_pdf_as_on_screen() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false, "margins": 0})).unwrap();
|
||||
// A gradient swatch (an off-page object's unnamed one), red → blue.
|
||||
let shape = s.execute("frame.create", &json!({"rect": [-60, 0, -50, 10], "content": "unassigned"})).unwrap()["id"].clone();
|
||||
s.execute("object.gradient", &json!({"ids": [shape], "stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]}))
|
||||
.unwrap();
|
||||
let g = s.doc().unwrap().doc.item(designcraft_doc::ItemId(shape.as_u64().unwrap())).unwrap().fill.swatch.clone();
|
||||
let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 120], "content": "text", "text": ""})).unwrap();
|
||||
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
|
||||
s.execute("table.insert", &json!({"rows": 1, "cols": 3})).unwrap();
|
||||
// A third of the frame's width: the cell's text, red → blue.
|
||||
let mut d: designcraft_doc::Document = (*s.doc().unwrap().doc).clone();
|
||||
let sid = d.stories.values().find(|st| !st.tables.is_empty()).unwrap().id;
|
||||
let t = std::sync::Arc::make_mut(d.story_mut(sid).unwrap().tables.values_mut().next().unwrap());
|
||||
let text = &mut t.cell_mut(0, 0).unwrap().text;
|
||||
text.insert(0, "HHHH");
|
||||
text.format_chars(0..4, |f| {
|
||||
f.over.fill = Some(g.clone());
|
||||
f.over.size = Some(24.0);
|
||||
});
|
||||
let mut rr = designcraft_render::Renderer::new();
|
||||
let cache = designcraft_compose::Cache::new();
|
||||
let screen = rr.render_page(&d, &cache, 0, 1.0, false, &Default::default()).unwrap();
|
||||
let pdf = designcraft_pdf::export_pdf(&d, &cache, &Default::default()).unwrap();
|
||||
let mut p = crate::Session::new();
|
||||
p.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false})).unwrap();
|
||||
p.execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name": "cell.pdf", "x": 0, "y": 0, "width": 300}))
|
||||
.unwrap();
|
||||
let placed = rr.render_page(&p.doc().unwrap().doc.clone(), &p.cache, 0, 1.0, false, &Default::default()).unwrap();
|
||||
let (x0, x1) = ink_span(&screen);
|
||||
assert!(x1 < 120, "the text stays in the first column: {x0}..{x1}");
|
||||
let q = (x1 - x0) / 4;
|
||||
for (what, img) in [("screen", &screen), ("pdf", &placed)] {
|
||||
let (lr, lb) = coloured_ink(img, x0, x0 + q);
|
||||
let (rr, rb) = coloured_ink(img, x1 + 1 - q, x1 + 1);
|
||||
assert!(lr > lb + 50.0, "{what}: the left ink is red ({lr:.0}, {lb:.0})");
|
||||
assert!(rb > rr + 50.0, "{what}: the right ink is blue ({rr:.0}, {rb:.0})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod compose_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A story "Ta" with the caret after "T" and 30 pt chosen for the next typing.
|
||||
fn session() -> (Session, u64) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "Ta"})).unwrap();
|
||||
let sid = r["story"].as_u64().unwrap();
|
||||
s.set_tool("type");
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 1})).unwrap();
|
||||
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
|
||||
(s, sid)
|
||||
}
|
||||
|
||||
fn text(s: &Session, sid: u64) -> String {
|
||||
s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text.clone()
|
||||
}
|
||||
|
||||
/// ⌥E shows the accent in the story, in the format it will be typed in, then I types "í":
|
||||
/// one undo step, and the accent was never part of the document.
|
||||
#[test]
|
||||
fn composed_text_shows_in_the_typing_format_until_it_is_typed() {
|
||||
let (mut s, sid) = session();
|
||||
let r = s.execute("text.compose", &json!({"text": "´"})).unwrap();
|
||||
assert_eq!(r["range"], json!([1, 3]));
|
||||
assert_eq!(text(&s, sid), "T´a");
|
||||
let size_at = |s: &Session, b| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().char_format_at(b).over.size;
|
||||
assert_eq!(size_at(&s, 2), Some(30.0), "the accent is shown in the format chosen at the caret");
|
||||
assert_eq!(s.composition().map(|c| c.2), Some(1..3));
|
||||
s.execute("text.compose", &json!({"text": "¨"})).unwrap();
|
||||
assert_eq!(text(&s, sid), "T¨a", "a new composition replaces the last one");
|
||||
s.execute("text.compose", &json!({"text": ""})).unwrap();
|
||||
assert_eq!(text(&s, sid), "Ta");
|
||||
assert!(s.composition().is_none());
|
||||
s.execute("text.insert", &json!({"text": "í"})).unwrap();
|
||||
assert_eq!(text(&s, sid), "Tía");
|
||||
assert_eq!(size_at(&s, 2), Some(30.0), "the typing format survived the composition");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, sid), "Ta");
|
||||
}
|
||||
|
||||
/// Anything else that edits, a tool change or a drag drops the uncommitted accent.
|
||||
#[test]
|
||||
fn other_edits_drop_the_composed_text() {
|
||||
for act in 0..3 {
|
||||
let (mut s, sid) = session();
|
||||
s.execute("text.compose", &json!({"text": "´"})).unwrap();
|
||||
match act {
|
||||
0 => {
|
||||
s.execute("text.move", &json!({"dir": "right"})).unwrap();
|
||||
}
|
||||
1 => s.set_tool("selection"),
|
||||
_ => s.begin_interaction("Move").unwrap(),
|
||||
}
|
||||
assert_eq!(text(&s, sid), "Ta", "case {act}");
|
||||
assert!(s.composition().is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod placeholder_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Fill with Placeholder Text gives paragraphs of a few sentences, as InDesign's does (it was one
|
||||
/// paragraph, and the filler ran sentences together: "interit.Ovid").
|
||||
#[test]
|
||||
fn placeholder_text_comes_in_paragraphs() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [36, 36, 336, 736], "content": "text", "text": ""})).unwrap();
|
||||
s.execute("type.fillWithPlaceholder", &json!({"frame": r["id"]})).unwrap();
|
||||
let st = s.execute("story.get", &json!({"story": r["story"]})).unwrap();
|
||||
let text = st["text"].as_str().unwrap();
|
||||
let paragraphs: Vec<&str> = text.split('\n').collect();
|
||||
assert!(paragraphs.len() >= 4, "{} paragraphs", paragraphs.len());
|
||||
for p in ¶graphs {
|
||||
assert!(p.ends_with('.') && !p.starts_with(' '), "a paragraph of whole sentences: {p:?}");
|
||||
}
|
||||
assert!(!text.contains(".O") && !text.contains(".L"), "sentences are spaced: {text}");
|
||||
assert!(st["overset"].is_null(), "nothing overset: {}", st["overset"]);
|
||||
assert!(text.len() > 1500, "the frame is filled: {}", text.len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +295,9 @@ pub struct Session {
|
||||
pub(crate) text_drag: bool,
|
||||
/// Character formatting chosen at a bare caret, for the text typed there next.
|
||||
pub(crate) typing_format: Option<cmd::text::TypingFormat>,
|
||||
/// Text an input method is composing (a dead key's accent), shown in the story as an
|
||||
/// interaction preview until it commits: (document uid, byte length before the caret).
|
||||
pub(crate) composition: Option<(u64, usize)>,
|
||||
/// The open Object Library (File › New / Open Library).
|
||||
pub library: Option<cmd::library::Library>,
|
||||
/// The open book (File › Open Book).
|
||||
@@ -332,6 +335,7 @@ impl Session {
|
||||
transforms: Default::default(),
|
||||
text_drag: false,
|
||||
typing_format: None,
|
||||
composition: None,
|
||||
library: None,
|
||||
conveyor: Vec::new(),
|
||||
book: None,
|
||||
@@ -441,6 +445,10 @@ impl Session {
|
||||
|
||||
fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
// Text still being composed isn't part of the story: any other edit drops it first.
|
||||
if id != "text.compose" && (spec.undoable || spec.journal) {
|
||||
self.end_composition();
|
||||
}
|
||||
if let Err(e) = (spec.enabled)(self) {
|
||||
// A command that takes `ids` acts on the objects the call names, so it needs no
|
||||
// selection: with a text caret or nothing selected, `{"ids": [5]}` still runs.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_doc::{CellAddr, StoryId};
|
||||
use designcraft_geom::Unit;
|
||||
pub use designcraft_tools::SnapView;
|
||||
use designcraft_tools::{Action, CanvasLayout, Cursor, Mods, Overlay, PointerEvent, ToolContext, ToolKey};
|
||||
@@ -60,6 +61,7 @@ impl Session {
|
||||
if self.tool.id() == id {
|
||||
return;
|
||||
}
|
||||
self.end_composition();
|
||||
self.tool = designcraft_tools::create(id);
|
||||
// Any other tool unloads the place cursor.
|
||||
if id != "placeGun" {
|
||||
@@ -150,7 +152,29 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The text an input method is composing at the caret, as story bytes: it is in the
|
||||
/// document as a preview until `text.compose` commits or drops it.
|
||||
pub fn composition(&self) -> Option<(StoryId, Option<CellAddr>, std::ops::Range<usize>)> {
|
||||
let (uid, len) = self.composition?;
|
||||
let st = self.active().filter(|st| st.uid == uid)?;
|
||||
let t = st.selection.text?;
|
||||
Some((t.story, t.cell, t.focus.checked_sub(len)?..t.focus))
|
||||
}
|
||||
|
||||
/// Drop the text being composed: the document goes back to how it was before it.
|
||||
pub fn end_composition(&mut self) {
|
||||
let Some((uid, _)) = self.composition.take() else { return };
|
||||
if let Some(st) = self.docs.iter_mut().find(|st| st.uid == uid)
|
||||
&& let Some(it) = st.interaction.take()
|
||||
{
|
||||
st.doc = it.doc;
|
||||
st.selection = it.selection;
|
||||
st.revision += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn begin_interaction(&mut self, label: &str) -> Result<()> {
|
||||
self.end_composition();
|
||||
let st = self.doc_mut()?;
|
||||
st.interaction = Some(Interaction { label: label.into(), doc: st.doc.clone(), selection: st.selection.clone(), preview: None });
|
||||
Ok(())
|
||||
|
||||
+117
-9
@@ -87,22 +87,51 @@ pub fn load(bytes: &[u8]) -> Result<Document, FormatError> {
|
||||
load_legacy_json(bytes)
|
||||
}
|
||||
|
||||
/// Most entries a `.designcraft` archive may hold.
|
||||
const MAX_ENTRIES: usize = 100_000;
|
||||
/// Most bytes `meta.json` or `document.json` may expand to.
|
||||
const MAX_JSON_BYTES: u64 = 256 << 20;
|
||||
/// Most bytes one embedded asset may expand to.
|
||||
const MAX_ASSET_BYTES: u64 = 1 << 30;
|
||||
/// Most bytes all the entries read from one archive may expand to together.
|
||||
const MAX_TOTAL_BYTES: u64 = 4 << 30;
|
||||
|
||||
/// Read entry `name` of `z`, or `None` when it is missing or unreadable. Refuses an entry that
|
||||
/// expands past `limit` bytes or past what is `left` of the archive's budget, without
|
||||
/// decompressing more than that: the sizes the archive claims are not trusted.
|
||||
fn read_entry(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, limit: u64, left: &mut u64) -> Result<Option<Vec<u8>>, FormatError> {
|
||||
let too_big = || FormatError::Io(format!("{name} is larger than the {limit}-byte limit (or the archive is larger than {MAX_TOTAL_BYTES} bytes)"));
|
||||
let Ok(f) = z.by_name(name) else { return Ok(None) };
|
||||
let limit = limit.min(*left);
|
||||
if f.size() > limit {
|
||||
return Err(too_big());
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
if f.take(limit.saturating_add(1)).read_to_end(&mut v).is_err() {
|
||||
return Ok(None);
|
||||
}
|
||||
let n = v.len() as u64;
|
||||
if n > limit {
|
||||
return Err(too_big());
|
||||
}
|
||||
*left = left.saturating_sub(n);
|
||||
Ok(Some(v))
|
||||
}
|
||||
|
||||
fn load_zip(bytes: &[u8]) -> Result<Document, FormatError> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?;
|
||||
let read = |z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str| -> Option<Vec<u8>> {
|
||||
let mut f = z.by_name(name).ok()?;
|
||||
let mut v = Vec::new();
|
||||
f.read_to_end(&mut v).ok()?;
|
||||
Some(v)
|
||||
};
|
||||
let version = read(&mut z, "meta.json")
|
||||
if z.len() > MAX_ENTRIES {
|
||||
return Err(FormatError::NotOurs(format!("{} entries (at most {MAX_ENTRIES})", z.len())));
|
||||
}
|
||||
let mut left = MAX_TOTAL_BYTES;
|
||||
let version = read_entry(&mut z, "meta.json", MAX_JSON_BYTES, &mut left)?
|
||||
.and_then(|meta| serde_json::from_slice::<Value>(&meta).ok())
|
||||
.and_then(|m| m.get("version").and_then(Value::as_u64))
|
||||
.map_or(1, |v| u32::try_from(v).unwrap_or(u32::MAX));
|
||||
if version > VERSION {
|
||||
return Err(FormatError::TooNew(version));
|
||||
}
|
||||
let json = read(&mut z, "document.json").ok_or_else(|| FormatError::NotOurs("missing document.json".into()))?;
|
||||
let json = read_entry(&mut z, "document.json", MAX_JSON_BYTES, &mut left)?.ok_or_else(|| FormatError::NotOurs("missing document.json".into()))?;
|
||||
let mut v: Value = serde_json::from_slice(&json).map_err(|e| FormatError::NotOurs(e.to_string()))?;
|
||||
upgrade_json(&mut v);
|
||||
let mut doc: Document = serde_json::from_value(v).map_err(|e| FormatError::NotOurs(e.to_string()))?;
|
||||
@@ -114,7 +143,11 @@ fn load_zip(bytes: &[u8]) -> Result<Document, FormatError> {
|
||||
for name in names {
|
||||
let Some(stem) = name.strip_prefix("assets/") else { continue };
|
||||
let Some(id) = stem.split('.').next().and_then(|s| s.parse::<u64>().ok()) else { continue };
|
||||
if let (Some(data), Some(a)) = (read(&mut z, &name), doc.assets.get_mut(&AssetId(id))) {
|
||||
// Entries for assets the document doesn't have are never decompressed.
|
||||
if !doc.assets.contains_key(&AssetId(id)) {
|
||||
continue;
|
||||
}
|
||||
if let (Some(data), Some(a)) = (read_entry(&mut z, &name, MAX_ASSET_BYTES, &mut left)?, doc.assets.get_mut(&AssetId(id))) {
|
||||
let same = loaded.entry(fingerprint.hash_one(data.as_slice())).or_default();
|
||||
let data = match same.iter().find(|d| ***d == data) {
|
||||
Some(d) => d.clone(),
|
||||
@@ -613,4 +646,79 @@ mod tests {
|
||||
}
|
||||
assert!(matches!(load(&buf.into_inner()), Err(FormatError::TooNew(99))));
|
||||
}
|
||||
|
||||
/// Write `len` bytes of `fill` to the current entry of `z`.
|
||||
fn write_fill(z: &mut zip::ZipWriter<Cursor<Vec<u8>>>, len: usize, fill: u8) {
|
||||
let chunk = vec![fill; 1 << 20];
|
||||
let mut left = len;
|
||||
while left > 0 {
|
||||
let n = left.min(chunk.len());
|
||||
z.write_all(&chunk[..n]).unwrap();
|
||||
left -= n;
|
||||
}
|
||||
}
|
||||
|
||||
/// Make the archive claim that entry `name` expands to `size` bytes, in both its local header
|
||||
/// and its central directory record (the archive has no ZIP64 fields or data descriptors).
|
||||
fn claim_size(bytes: &mut [u8], name: &str, size: u32) {
|
||||
let mut patched = 0;
|
||||
for i in 0..bytes.len().saturating_sub(46) {
|
||||
let (size_at, len_at, name_at) = match &bytes[i..i + 4] {
|
||||
b"PK\x03\x04" => (22, 26, 30),
|
||||
b"PK\x01\x02" => (24, 28, 46),
|
||||
_ => continue,
|
||||
};
|
||||
let len = u16::from_le_bytes([bytes[i + len_at], bytes[i + len_at + 1]]) as usize;
|
||||
if bytes.get(i + name_at..i + name_at + len) == Some(name.as_bytes()) {
|
||||
bytes[i + size_at..i + size_at + 4].copy_from_slice(&size.to_le_bytes());
|
||||
patched += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(patched, 2, "{name} not found in both headers");
|
||||
}
|
||||
|
||||
/// A small archive whose `document.json` expands past the 256 MiB limit while claiming to
|
||||
/// hold one byte is refused once the limit is reached, with an error that says so, instead of
|
||||
/// being read whole into memory.
|
||||
#[test]
|
||||
fn oversized_document_json_is_refused() {
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
z.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
write_fill(&mut z, (256 << 20) + 1, b' ');
|
||||
let mut bytes = z.finish().unwrap().into_inner();
|
||||
assert!(bytes.len() < 4 << 20, "{}", bytes.len());
|
||||
claim_size(&mut bytes, "document.json", 1);
|
||||
let Err(err) = load(&bytes) else { panic!("an oversized document.json loaded") };
|
||||
assert!(err.to_string().contains("limit"), "{err}");
|
||||
}
|
||||
|
||||
/// An embedded file that expands past the 1 GiB limit (and says so) is refused too.
|
||||
#[test]
|
||||
fn oversized_asset_is_refused() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let aid = png_asset(&mut d, &[1]);
|
||||
place(&mut d, aid, 0.0);
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
z.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(&serde_json::to_vec(&d).unwrap()).unwrap();
|
||||
z.start_file(format!("assets/{}.png", aid.0), zip::write::SimpleFileOptions::default()).unwrap();
|
||||
write_fill(&mut z, (1 << 30) + 1, 0);
|
||||
let Err(err) = load(&z.finish().unwrap().into_inner()) else { panic!("an oversized asset loaded") };
|
||||
assert!(err.to_string().contains("limit"), "{err}");
|
||||
}
|
||||
|
||||
/// An archive with more entries than any document needs is refused before any is read.
|
||||
#[test]
|
||||
fn too_many_entries_are_refused() {
|
||||
let d = Document::new(&NewDocument::default());
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
z.start_file("document.json", stored).unwrap();
|
||||
z.write_all(&serde_json::to_vec(&d).unwrap()).unwrap();
|
||||
for i in 0..100_000 {
|
||||
z.start_file(format!("junk/{i}"), stored).unwrap();
|
||||
}
|
||||
let Err(err) = load(&z.finish().unwrap().into_inner()) else { panic!("an archive with 100 001 entries loaded") };
|
||||
assert!(err.to_string().contains("entries"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1667,6 +1667,7 @@ impl<'a> Ex<'a> {
|
||||
.attr("Self", uid(pg.id.0))
|
||||
.attr("Name", name)
|
||||
.attr("AppliedMaster", applied.map(|s| uid(s.0)).unwrap_or_else(|| "n".into()))
|
||||
.attr("LayoutRule", names::liquid_rule_out(pg.liquid))
|
||||
.attr("OverrideList", overrides.join(" "))
|
||||
.attr("GeometricBounds", format!("0 0 {} {}", num(pg.height), num(pg.width)))
|
||||
.attr("ItemTransform", format!("1 0 0 1 {} {}", num(pg.x - ox), num(-oy)))
|
||||
@@ -1775,6 +1776,14 @@ impl<'a> Ex<'a> {
|
||||
} else if let Some(a) = it.fill.gradient_angle {
|
||||
el.set("GradientFillAngle", num(a));
|
||||
}
|
||||
// The same on the stroke.
|
||||
if let Some([x0, y0, x1, y1]) = it.stroke.gradient_vector {
|
||||
el.set("GradientStrokeStart", format!("{} {}", num(x0), num(y0)));
|
||||
el.set("GradientStrokeLength", num((x1 - x0).hypot(y1 - y0)));
|
||||
el.set("GradientStrokeAngle", num((-(y1 - y0)).atan2(x1 - x0).to_degrees()));
|
||||
} else if let Some(a) = it.stroke.gradient_angle {
|
||||
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() {
|
||||
@@ -1911,6 +1920,11 @@ impl<'a> Ex<'a> {
|
||||
.attr("ImportPolicy", "NoAutoImport")
|
||||
.attr("ExportPolicy", "NoAutoExport"),
|
||||
);
|
||||
if is_pdf && asset.page > 0 {
|
||||
// PDF Import Options: the page is 1-based; page 1 (the default) needs no element, as
|
||||
// InDesign omits it too.
|
||||
el.push(El::new("PDFAttribute").attr("PageNumber", asset.page.saturating_add(1).to_string()));
|
||||
}
|
||||
el
|
||||
}
|
||||
|
||||
|
||||
+135
-36
@@ -52,52 +52,38 @@ pub struct Imported {
|
||||
|
||||
/// Import with a custom reader for linked files, reporting the problems that did not stop it.
|
||||
pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Imported> {
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| IdmlError::NotIdml(e.to_string()))?;
|
||||
let mut files: HashMap<String, Vec<u8>> = HashMap::new();
|
||||
// Normalised part name → entry name, first entry wins (zip order, so the choice is stable).
|
||||
let mut lenient: HashMap<String, String> = HashMap::new();
|
||||
for i in 0..zip.len() {
|
||||
let mut f = zip.by_index(i).map_err(|e| IdmlError::NotIdml(e.to_string()))?;
|
||||
// A folder entry of the archive (the zip crate's own `is_dir`; spelled out so no file-system
|
||||
// probe appears in this crate).
|
||||
if f.name().ends_with('/') {
|
||||
continue;
|
||||
}
|
||||
let name = f.name().to_string();
|
||||
// The size is what the archive claims: don't reserve more than a sane part up front.
|
||||
let mut buf = Vec::with_capacity((f.size() as usize).min(1 << 24));
|
||||
f.read_to_end(&mut buf).map_err(|e| IdmlError::Part { part: name.clone(), msg: e.to_string() })?;
|
||||
lenient.entry(normalize_part_name(&name)).or_insert_with(|| name.clone());
|
||||
files.insert(name, buf);
|
||||
}
|
||||
let part = |name: &str| files.get(name).or_else(|| lenient.get(&normalize_part_name(name)).and_then(|n| files.get(n)));
|
||||
if let Some(m) = part("mimetype")
|
||||
&& String::from_utf8_lossy(m).trim() != MIMETYPE
|
||||
let mut pkg = Package::open(bytes)?;
|
||||
if let Some(m) = pkg.part("mimetype")?
|
||||
&& String::from_utf8_lossy(&m).trim() != MIMETYPE
|
||||
{
|
||||
return Err(IdmlError::NotIdml(format!("unexpected mimetype `{}`", String::from_utf8_lossy(m).trim())));
|
||||
return Err(IdmlError::NotIdml(format!("unexpected mimetype `{}`", String::from_utf8_lossy(&m).trim())));
|
||||
}
|
||||
let dm = part("designmap.xml").ok_or_else(|| IdmlError::NotIdml("missing designmap.xml".into()))?;
|
||||
let root = parse(dm).map_err(|msg| IdmlError::Part { part: "designmap.xml".into(), msg })?;
|
||||
let dm = pkg.part("designmap.xml")?.ok_or_else(|| IdmlError::NotIdml("missing designmap.xml".into()))?;
|
||||
let root = parse(&dm).map_err(|msg| IdmlError::Part { part: "designmap.xml".into(), msg })?;
|
||||
if root.local() != "Document" {
|
||||
return Err(IdmlError::NotIdml(format!("designmap root is <{}>", root.name)));
|
||||
}
|
||||
// Flatten includes: every `idPkg:*` child is replaced by the children of its part's root.
|
||||
let mut top: Vec<El> = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
// Only the parts the import reads count (unreferenced entries are never decompressed).
|
||||
let mut japanese_composer = names_japanese_composer(&dm);
|
||||
for c in root.elements() {
|
||||
if c.name.starts_with("idPkg:") {
|
||||
let Some(src) = c.get("src") else { continue };
|
||||
let Some(data) = part(src) else {
|
||||
let Some(data) = pkg.part(src)? else {
|
||||
warnings.push(format!("{src}: listed in designmap.xml but missing from the package; its contents were not imported"));
|
||||
continue;
|
||||
};
|
||||
let el = parse(data).map_err(|msg| IdmlError::Part { part: src.into(), msg })?;
|
||||
japanese_composer |= names_japanese_composer(&data);
|
||||
let el = parse(&data).map_err(|msg| IdmlError::Part { part: src.into(), msg })?;
|
||||
top.extend(el.elements().cloned());
|
||||
} else {
|
||||
top.push(c.clone());
|
||||
}
|
||||
}
|
||||
let mut im = Importer::new(read_link);
|
||||
im.japanese_composer = japanese_composer;
|
||||
im.run(&root, &top)?;
|
||||
let document = im.finish(&root).map_err(|e| match e {
|
||||
// Without its spread parts there is nothing to open: say which parts were missing.
|
||||
@@ -116,6 +102,85 @@ enum RulerOrigin {
|
||||
Spine,
|
||||
}
|
||||
|
||||
/// Most entries an IDML package may hold.
|
||||
const MAX_ENTRIES: usize = 100_000;
|
||||
/// Most bytes one package part may expand to.
|
||||
const MAX_PART_BYTES: u64 = 512 << 20;
|
||||
/// Most bytes all the parts read from one package may expand to together.
|
||||
const MAX_TOTAL_BYTES: u64 = 2 << 30;
|
||||
|
||||
/// An IDML package whose parts are decompressed only when the import asks for them, within
|
||||
/// byte budgets that don't trust the sizes the archive claims.
|
||||
struct Package<'a> {
|
||||
zip: zip::ZipArchive<std::io::Cursor<&'a [u8]>>,
|
||||
/// Normalised part name → entry name, first entry wins (zip order, so the choice is stable).
|
||||
lenient: HashMap<String, String>,
|
||||
/// Bytes the parts still to be read may expand to.
|
||||
left: u64,
|
||||
}
|
||||
|
||||
impl<'a> Package<'a> {
|
||||
fn open(bytes: &'a [u8]) -> Result<Self> {
|
||||
let zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| IdmlError::NotIdml(e.to_string()))?;
|
||||
if zip.len() > MAX_ENTRIES {
|
||||
return Err(IdmlError::NotIdml(format!("{} entries (at most {MAX_ENTRIES})", zip.len())));
|
||||
}
|
||||
let mut lenient: HashMap<String, String> = HashMap::new();
|
||||
for i in 0..zip.len() {
|
||||
let Some(name) = zip.name_for_index(i) else { continue };
|
||||
if name.ends_with('/') {
|
||||
continue;
|
||||
}
|
||||
lenient.entry(normalize_part_name(name)).or_insert_with(|| name.to_string());
|
||||
}
|
||||
Ok(Package { zip, lenient, left: MAX_TOTAL_BYTES })
|
||||
}
|
||||
|
||||
/// The bytes of part `name` (matched exactly, then leniently), or `None` if the package has
|
||||
/// no such part.
|
||||
fn part(&mut self, name: &str) -> Result<Option<Vec<u8>>> {
|
||||
let exact = self.zip.index_for_name(name).filter(|_| !name.ends_with('/'));
|
||||
let Some(i) = exact.or_else(|| self.lenient.get(&normalize_part_name(name)).and_then(|n| self.zip.index_for_name(n))) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let f = self.zip.by_index(i).map_err(|e| IdmlError::NotIdml(e.to_string()))?;
|
||||
let part = f.name().to_string();
|
||||
let limit = MAX_PART_BYTES.min(self.left);
|
||||
let too_big = || IdmlError::Part {
|
||||
part: part.clone(),
|
||||
msg: format!("larger than the {MAX_PART_BYTES}-byte part limit (or the package is larger than {MAX_TOTAL_BYTES} bytes)"),
|
||||
};
|
||||
if f.size() > limit {
|
||||
return Err(too_big());
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
f.take(limit.saturating_add(1)).read_to_end(&mut buf).map_err(|e| IdmlError::Part { part: part.clone(), msg: e.to_string() })?;
|
||||
let n = buf.len() as u64;
|
||||
if n > limit {
|
||||
return Err(too_big());
|
||||
}
|
||||
self.left = self.left.saturating_sub(n);
|
||||
Ok(Some(buf))
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this package part apply one of InDesign's Japanese composers (`HL Composer J`,
|
||||
/// `HL Single J`) anywhere? Only they use the CJK leading model.
|
||||
fn names_japanese_composer(data: &[u8]) -> bool {
|
||||
const KEY: &[u8] = b"Composer=\"";
|
||||
let mut rest = data;
|
||||
while let Some(at) = rest.windows(KEY.len()).position(|w| w == KEY) {
|
||||
let value = rest.get(at + KEY.len()..).unwrap_or_default();
|
||||
let end = value.iter().position(|b| *b == b'"').unwrap_or(value.len());
|
||||
let name = String::from_utf8_lossy(value.get(..end).unwrap_or_default());
|
||||
if name.ends_with(" J") || name.contains("Japanese") {
|
||||
return true;
|
||||
}
|
||||
rest = value.get(end..).unwrap_or_default();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// A package part name in the form the lookup compares: InDesign and other IDML writers differ
|
||||
/// in percent-encoding, case, path separators and a leading `./` or `/`.
|
||||
fn normalize_part_name(name: &str) -> String {
|
||||
@@ -161,6 +226,8 @@ struct Importer<'r> {
|
||||
read_link: &'r dyn Fn(&str) -> Option<Vec<u8>>,
|
||||
/// View Preferences › Ruler Origin: where a guide's Location is measured from.
|
||||
ruler_origin: RulerOrigin,
|
||||
/// The package applies a Japanese composer somewhere (see [`names_japanese_composer`]).
|
||||
japanese_composer: bool,
|
||||
next_id: u64,
|
||||
settings: DocSettings,
|
||||
default_margins: Option<(Margins, Columns)>,
|
||||
@@ -196,8 +263,9 @@ struct Importer<'r> {
|
||||
page_index: HashMap<String, usize>,
|
||||
item_ids: HashMap<String, ItemId>,
|
||||
assets: BTreeMap<AssetId, Arc<Asset>>,
|
||||
/// Assets read from a link path, so later placements of that path reuse them unread.
|
||||
linked_assets: HashMap<String, AssetId>,
|
||||
/// Assets read from a link path at a page, so later placements of that path and page reuse
|
||||
/// them unread.
|
||||
linked_assets: HashMap<(String, u32), AssetId>,
|
||||
ctx: ItemCtx,
|
||||
sections: Vec<Section>,
|
||||
footnote_options: designcraft_doc::FootnoteOptions,
|
||||
@@ -316,6 +384,7 @@ impl<'r> Importer<'r> {
|
||||
Importer {
|
||||
read_link,
|
||||
ruler_origin: RulerOrigin::Spread,
|
||||
japanese_composer: false,
|
||||
next_id: 0,
|
||||
settings: DocSettings::default(),
|
||||
default_margins: None,
|
||||
@@ -949,7 +1018,13 @@ impl<'r> Importer<'r> {
|
||||
let based_on = if name == designcraft_doc::NO_PARA_STYLE { None } else { self.based_on(e, true) };
|
||||
let next_style = e.get("NextStyle").map(|n| self.para_style_ref(n)).filter(|n| *n != name);
|
||||
let para = self.para_attrs(e);
|
||||
let chars = self.char_attrs(e);
|
||||
let mut chars = self.char_attrs(e);
|
||||
// InDesign writes its CJK leading model (Aki Below) on [No paragraph style] of every
|
||||
// document; only the Japanese composers use it, so a document set with the Paragraph or
|
||||
// Single-line Composer spaces its lines baseline to baseline.
|
||||
if name == designcraft_doc::NO_PARA_STYLE && !self.japanese_composer {
|
||||
chars.leading_model = None;
|
||||
}
|
||||
let s = ParagraphStyle { name: name.clone(), based_on, next_style, para, chars, shortcut: String::new() };
|
||||
match self.styles.para_mut(&name) {
|
||||
Some(slot) => *slot = s,
|
||||
@@ -2098,7 +2173,7 @@ impl<'r> Importer<'r> {
|
||||
overridden: vec![],
|
||||
guides: self.guides_of(p, Rect::new(r.x0 - min_x, r.y0 - min_y, r.x1 - min_x, r.y1 - min_y), spine),
|
||||
show_parent_items: show,
|
||||
liquid: Default::default(),
|
||||
liquid: names::liquid_rule_in(p.get("LayoutRule").unwrap_or("Off")),
|
||||
transition: None,
|
||||
view_rotation: 0,
|
||||
show_parent_overlay: false,
|
||||
@@ -2277,6 +2352,19 @@ impl<'r> Importer<'r> {
|
||||
it.stroke.swatch = swatch::NONE.into();
|
||||
it.stroke.weight = 1.0;
|
||||
}
|
||||
// A gradient on the stroke: its angle, or the Gradient Swatch tool's vector.
|
||||
if let Some(a) = e.num("GradientStrokeAngle")
|
||||
&& a != 0.0
|
||||
{
|
||||
it.stroke.gradient_angle = Some(a);
|
||||
}
|
||||
let start: Option<Vec<f64>> = e.prop("GradientStrokeStart").map(|s| s.split_whitespace().filter_map(|v| v.parse().ok()).collect());
|
||||
if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientStrokeLength"))
|
||||
&& len > 0.0
|
||||
{
|
||||
let a = e.num("GradientStrokeAngle").unwrap_or(0.0).to_radians();
|
||||
it.stroke.gradient_vector = Some([x, y, x + len * a.cos(), y - len * a.sin()]);
|
||||
}
|
||||
}
|
||||
// Corners: each corner's shape and radius come from the item, then its object style chain;
|
||||
// at each level a per-corner attribute wins over the legacy all-corners one.
|
||||
@@ -2461,18 +2549,29 @@ impl<'r> Importer<'r> {
|
||||
})
|
||||
};
|
||||
let mut data = g.prop_el("Contents").map(|c| base64_decode(&c.text_content())).unwrap_or_default();
|
||||
// PDF Import Options: `PDFAttribute/@PageNumber` is 1-based; `Asset.page` is 0-based. A
|
||||
// missing or malformed value means page 1, and an out-of-range page later fails to render
|
||||
// rather than panicking.
|
||||
let page = g
|
||||
.find("PDFAttribute")
|
||||
.or_else(|| g.prop_el("PDFAttribute"))
|
||||
.and_then(|a| a.get("PageNumber"))
|
||||
.and_then(|v| v.trim().parse::<u64>().ok())
|
||||
.and_then(|n| u32::try_from(n.saturating_sub(1)).ok())
|
||||
.unwrap_or(0);
|
||||
let mut read_from = None;
|
||||
if data.is_empty()
|
||||
&& let Some(p) = &uri
|
||||
{
|
||||
// Every placement of a linked file shares one asset; read the file once.
|
||||
if let Some(id) = self.linked_assets.get(p) {
|
||||
// Every placement of a linked file at one page shares one asset; read the file once.
|
||||
let key = (p.clone(), page);
|
||||
if let Some(id) = self.linked_assets.get(&key) {
|
||||
let size = self.assets.get(id).and_then(|a| svg_viewbox_size(&a.data)).unwrap_or(size);
|
||||
return graphic(*id, size);
|
||||
}
|
||||
if let Some(d) = (self.read_link)(p) {
|
||||
data = d;
|
||||
read_from = Some(p.clone());
|
||||
read_from = Some(key);
|
||||
}
|
||||
}
|
||||
// InDesign draws a viewBox-only SVG at one unit to the point: its GraphicBounds are the
|
||||
@@ -2495,9 +2594,9 @@ impl<'r> Importer<'r> {
|
||||
let fresh = AssetId(self.alloc());
|
||||
let link_path = uri.filter(|p| p.contains('/') || p.contains('\\'));
|
||||
let id =
|
||||
designcraft_doc::insert_asset(&mut self.assets, Asset { page: 0, id: fresh, name, mime, link: link_path, data: Arc::new(data), pixels });
|
||||
if let Some(p) = read_from {
|
||||
self.linked_assets.insert(p, id);
|
||||
designcraft_doc::insert_asset(&mut self.assets, Asset { page, id: fresh, name, mime, link: link_path, data: Arc::new(data), pixels });
|
||||
if let Some(key) = read_from {
|
||||
self.linked_assets.insert(key, id);
|
||||
}
|
||||
graphic(id, size)
|
||||
}
|
||||
|
||||
@@ -74,6 +74,31 @@ pub fn fitting_in(s: &str) -> designcraft_doc::Fitting {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn liquid_rule_out(rule: designcraft_doc::LiquidRule) -> &'static str {
|
||||
use designcraft_doc::LiquidRule as R;
|
||||
match rule {
|
||||
R::Off => "Off",
|
||||
R::Scale => "Scale",
|
||||
R::ReCenter => "Recenter",
|
||||
R::GuideBased => "GuideBased",
|
||||
R::ObjectBased => "ObjectBased",
|
||||
R::ControlledByParent => "UseMaster",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn liquid_rule_in(s: &str) -> designcraft_doc::LiquidRule {
|
||||
use designcraft_doc::LiquidRule as R;
|
||||
match s {
|
||||
"Off" => R::Off,
|
||||
"Scale" => R::Scale,
|
||||
"Recenter" => R::ReCenter,
|
||||
"GuideBased" => R::GuideBased,
|
||||
"ObjectBased" => R::ObjectBased,
|
||||
"UseMaster" => R::ControlledByParent,
|
||||
_ => R::Off,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn escape_id(s: &str) -> String {
|
||||
s.replace('%', "%25").replace(':', "%3a")
|
||||
}
|
||||
|
||||
@@ -2176,6 +2176,23 @@ fn type_on_a_path_keeps_its_start_and_end_brackets() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_strokes_gradient_vector_round_trips() {
|
||||
let mut d = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default());
|
||||
let id = designcraft_doc::ItemId(d.alloc());
|
||||
let mut it = designcraft_doc::Item::new(
|
||||
id,
|
||||
d.default_layer(),
|
||||
designcraft_doc::Shape::Path,
|
||||
designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(10.0, 10.0, 110.0, 60.0)),
|
||||
);
|
||||
it.stroke.gradient_vector = Some([10.0, 35.0, 110.0, 35.0]);
|
||||
d.insert_item(designcraft_doc::SpreadRef::Doc(0), it, None).unwrap();
|
||||
let back = crate::import_idml(&crate::export_idml(&d)).unwrap();
|
||||
let v = back.spreads[0].items[0].stroke.gradient_vector.unwrap();
|
||||
assert!(v.iter().zip([10.0, 35.0, 110.0, 35.0]).all(|(a, b)| (a - b).abs() < 1e-6), "{v:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_preference_import_caps_column_count_without_composition() {
|
||||
for (raw, expected) in [("4000000000", 216), ("1e300", 216), ("-100", 1), ("0", 1), ("NaN", 1), ("inf", 1), ("-inf", 1)] {
|
||||
@@ -2269,3 +2286,277 @@ fn ruler_guides_import_and_round_trip() {
|
||||
assert_eq!(guides(&back), guides(&d));
|
||||
assert_eq!(back.parents[0].pages[0].guides.len(), 1);
|
||||
}
|
||||
|
||||
/// A package entry nothing in `designmap.xml` refers to is never decompressed: a damaged one
|
||||
/// (its checksum no longer matches) does not stop the import.
|
||||
#[test]
|
||||
fn unreferenced_entries_are_not_read() {
|
||||
use zip::write::SimpleFileOptions;
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
w.start_file("mimetype", stored).unwrap();
|
||||
w.write_all(MIMETYPE.as_bytes()).unwrap();
|
||||
for (n, c) in [
|
||||
("designmap.xml", DESIGNMAP),
|
||||
("Resources/Graphic.xml", GRAPHIC),
|
||||
("Resources/Styles.xml", STYLES),
|
||||
("Resources/Preferences.xml", PREFS),
|
||||
("MasterSpreads/MasterSpread_m1.xml", MASTER),
|
||||
("Spreads/Spread_sp1.xml", SPREAD),
|
||||
("Stories/Story_s1.xml", STORY),
|
||||
] {
|
||||
w.start_file(n, SimpleFileOptions::default()).unwrap();
|
||||
w.write_all(c.as_bytes()).unwrap();
|
||||
}
|
||||
w.start_file("junk/irrelevant.bin", stored).unwrap();
|
||||
w.write_all(b"<<payload-of-an-unused-entry>>").unwrap();
|
||||
let mut bytes = w.finish().unwrap().into_inner();
|
||||
let at = bytes.windows(9).position(|w| w == b"<<payload").unwrap();
|
||||
bytes[at] = b'X';
|
||||
let d = import_idml(&bytes).unwrap();
|
||||
assert_eq!(d.spreads.len(), 1);
|
||||
}
|
||||
|
||||
/// A part that expands past the 512 MiB limit while claiming to hold one byte is refused once the
|
||||
/// limit is reached, with an error that says so, instead of being read whole into memory.
|
||||
#[test]
|
||||
fn oversized_part_is_refused() {
|
||||
use zip::write::SimpleFileOptions;
|
||||
let designmap = r#"<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" Self="d">
|
||||
<idPkg:Story src="Stories/Story_big.xml"/>
|
||||
</Document>"#;
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
w.start_file("mimetype", SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored)).unwrap();
|
||||
w.write_all(MIMETYPE.as_bytes()).unwrap();
|
||||
w.start_file("designmap.xml", SimpleFileOptions::default()).unwrap();
|
||||
w.write_all(designmap.as_bytes()).unwrap();
|
||||
w.start_file("Stories/Story_big.xml", SimpleFileOptions::default()).unwrap();
|
||||
let chunk = vec![b' '; 1 << 20];
|
||||
for _ in 0..512 {
|
||||
w.write_all(&chunk).unwrap();
|
||||
}
|
||||
w.write_all(b" ").unwrap();
|
||||
let mut bytes = w.finish().unwrap().into_inner();
|
||||
assert!(bytes.len() < 8 << 20, "{}", bytes.len());
|
||||
// Claim one byte in the local header and the central directory record (no ZIP64 fields or
|
||||
// data descriptors here), so only the bytes actually decompressed can show the part is too big.
|
||||
let mut patched = 0;
|
||||
for i in 0..bytes.len().saturating_sub(46) {
|
||||
let (size_at, len_at, name_at) = match &bytes[i..i + 4] {
|
||||
b"PK\x03\x04" => (22, 26, 30),
|
||||
b"PK\x01\x02" => (24, 28, 46),
|
||||
_ => continue,
|
||||
};
|
||||
let len = u16::from_le_bytes([bytes[i + len_at], bytes[i + len_at + 1]]) as usize;
|
||||
if bytes.get(i + name_at..i + name_at + len) == Some(b"Stories/Story_big.xml".as_slice()) {
|
||||
bytes[i + size_at..i + size_at + 4].copy_from_slice(&1u32.to_le_bytes());
|
||||
patched += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(patched, 2);
|
||||
let Err(err) = import_idml(&bytes) else { panic!("an oversized part imported") };
|
||||
assert!(err.to_string().contains("limit"), "{err}");
|
||||
}
|
||||
|
||||
/// A package with more entries than any document needs is refused before any is read.
|
||||
#[test]
|
||||
fn too_many_entries_are_refused() {
|
||||
use zip::write::SimpleFileOptions;
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
w.start_file("mimetype", stored).unwrap();
|
||||
w.write_all(MIMETYPE.as_bytes()).unwrap();
|
||||
for i in 0..100_000 {
|
||||
w.start_file(format!("junk/{i}"), stored).unwrap();
|
||||
}
|
||||
let Err(err) = import_idml(&w.finish().unwrap().into_inner()) else { panic!("a package with 100 001 entries imported") };
|
||||
assert!(err.to_string().contains("entries"), "{err}");
|
||||
}
|
||||
|
||||
/// InDesign writes `LeadingModel="LeadingModelAkiBelow"` on [No paragraph style] of every
|
||||
/// document, but only its Japanese composers use the leading model: with the Paragraph Composer,
|
||||
/// a 24 pt paragraph after a 32 pt one sits a Roman 28.8 pt below it (Montilla Design's Flex Layout
|
||||
/// file: frames fitted to that came out overset).
|
||||
#[test]
|
||||
fn the_default_cjk_leading_model_applies_only_with_a_japanese_composer() {
|
||||
let styles = |composer: &str| {
|
||||
STYLES.replace(
|
||||
r#"Name="$ID/[No paragraph style]" PointSize="12">"#,
|
||||
&format!(r#"Name="$ID/[No paragraph style]" PointSize="12" LeadingModel="LeadingModelAkiBelow" Composer="{composer}">"#),
|
||||
)
|
||||
};
|
||||
let model = |composer: &str| {
|
||||
let s = styles(composer);
|
||||
let pkg = zip_files(&[
|
||||
("designmap.xml", DESIGNMAP),
|
||||
("Resources/Graphic.xml", GRAPHIC),
|
||||
("Resources/Styles.xml", &s),
|
||||
("Resources/Preferences.xml", PREFS),
|
||||
("MasterSpreads/MasterSpread_m1.xml", MASTER),
|
||||
("Spreads/Spread_sp1.xml", SPREAD),
|
||||
("Stories/Story_s1.xml", STORY),
|
||||
]);
|
||||
let d = import_idml(&pkg).unwrap();
|
||||
d.styles.para(designcraft_doc::NO_PARA_STYLE).unwrap().chars.leading_model
|
||||
};
|
||||
assert_eq!(model("HL Composer"), None, "the Paragraph Composer leads baseline to baseline");
|
||||
assert_eq!(model("HL Single"), None);
|
||||
assert_eq!(model("$ID/HL Composer J"), Some(designcraft_doc::cjk::LeadingModel::AkiBelow));
|
||||
assert_eq!(model("$ID/HL Single J"), Some(designcraft_doc::cjk::LeadingModel::AkiBelow));
|
||||
}
|
||||
|
||||
mod liquid_page_rules {
|
||||
use super::*;
|
||||
use designcraft_doc::LiquidRule as R;
|
||||
use std::sync::Arc;
|
||||
|
||||
// Literal XML expectations, independent of names.rs and serde spellings.
|
||||
const CASES: [(&str, R); 6] = [
|
||||
("Scale", R::Scale),
|
||||
("Off", R::Off),
|
||||
("Recenter", R::ReCenter),
|
||||
("GuideBased", R::GuideBased),
|
||||
("ObjectBased", R::ObjectBased),
|
||||
("UseMaster", R::ControlledByParent),
|
||||
];
|
||||
|
||||
fn fixture(rule: Option<&str>, parent_rule: Option<&str>) -> Vec<u8> {
|
||||
let attribute = |value: Option<&str>| value.map(|v| format!(r#" LayoutRule="{v}""#)).unwrap_or_default();
|
||||
let (rule, parent_rule) = (attribute(rule), attribute(parent_rule));
|
||||
let map = r#"<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" Self="d">
|
||||
<idPkg:Preferences src="Resources/Preferences.xml"/>
|
||||
<idPkg:MasterSpread src="MasterSpreads/A.xml"/>
|
||||
<idPkg:MasterSpread src="MasterSpreads/B.xml"/>
|
||||
<idPkg:Spread src="Spreads/S.xml"/>
|
||||
</Document>"#;
|
||||
let parent = format!(
|
||||
r#"<idPkg:MasterSpread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
|
||||
<MasterSpread Self="ma" NamePrefix="A" BaseName="Base" PageCount="2">
|
||||
<Page Self="a1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 -500 -350"/>
|
||||
<Page Self="a2"{parent_rule} GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 0 -350"/>
|
||||
</MasterSpread>
|
||||
</idPkg:MasterSpread>"#
|
||||
);
|
||||
// Based-on belongs to the whole derived master: both pages apply the same master.
|
||||
let derived = format!(
|
||||
r#"<idPkg:MasterSpread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
|
||||
<MasterSpread Self="mb" NamePrefix="B" BaseName="Derived" PageCount="2">
|
||||
<Page Self="b1"{rule} AppliedMaster="ma" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 -500 -350"/>
|
||||
<Page Self="b2" AppliedMaster="ma" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 0 -350"/>
|
||||
</MasterSpread>
|
||||
</idPkg:MasterSpread>"#
|
||||
);
|
||||
let spread = format!(
|
||||
r#"<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
|
||||
<Spread Self="s" PageCount="2">
|
||||
<Page Self="p1" AppliedMaster="ma" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 -500 -350">
|
||||
<MarginPreference ColumnCount="3" ColumnGutter="12" Top="10" Bottom="20" Left="30" Right="40"/>
|
||||
</Page>
|
||||
<Page Self="p2"{rule} AppliedMaster="ma" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 0 -350"/>
|
||||
</Spread>
|
||||
</idPkg:Spread>"#
|
||||
);
|
||||
zip_files(&[
|
||||
("designmap.xml", map),
|
||||
("Resources/Preferences.xml", PREFS),
|
||||
("MasterSpreads/A.xml", &parent),
|
||||
("MasterSpreads/B.xml", &derived),
|
||||
("Spreads/S.xml", &spread),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_literal_xml() {
|
||||
let baseline = import_idml(&fixture(None, None)).unwrap();
|
||||
assert_eq!((baseline.spreads.len(), baseline.parents.len()), (1, 2));
|
||||
assert!(baseline.spreads.iter().chain(&baseline.parents).all(|sp| sp.pages.len() == 2));
|
||||
assert!(baseline.spreads.iter().chain(&baseline.parents).flat_map(|sp| &sp.pages).all(|pg| pg.liquid == R::Off));
|
||||
let cases = CASES.into_iter().map(|(literal, rule)| (Some(literal), rule)).chain([
|
||||
(None, R::Off),
|
||||
(Some("ReCenter"), R::Off),
|
||||
(Some("controlledByParent"), R::Off),
|
||||
(Some("PreserveExisting"), R::Off),
|
||||
]);
|
||||
for (literal, rule) in cases {
|
||||
let actual = import_idml(&fixture(literal, Some("Recenter"))).unwrap();
|
||||
assert_eq!(actual.spreads[0].pages[1].liquid, rule, "ordinary Page: {literal:?}");
|
||||
assert_eq!(actual.parents[1].pages[0].liquid, rule, "master Page: {literal:?}");
|
||||
// Same XML without rule attributes supplies the complete allowed-delta oracle.
|
||||
let mut expected = baseline.clone();
|
||||
Arc::make_mut(&mut expected.spreads[0]).pages[1].liquid = rule;
|
||||
Arc::make_mut(&mut expected.parents[1]).pages[0].liquid = rule;
|
||||
Arc::make_mut(&mut expected.parents[0]).pages[1].liquid = R::ReCenter;
|
||||
// Import assigns a fresh creation timestamp; all other document fields are compared.
|
||||
expected.created = actual.created;
|
||||
assert_eq!(actual, expected, "{literal:?}: only the three explicit rule fields may change apart from the import timestamp");
|
||||
assert_eq!(actual.spreads[0].pages[0].liquid, R::Off, "omitted does not infer inheritance");
|
||||
assert_eq!(actual.spreads[0].pages[0].parent, Some(actual.parents[0].id));
|
||||
assert_eq!(actual.parents[1].parent.as_ref().unwrap().based_on, Some(actual.parents[0].id));
|
||||
assert!(actual.parents.iter().flat_map(|sp| &sp.pages).all(|pg| pg.parent.is_none()));
|
||||
if rule == R::ControlledByParent {
|
||||
// The right document page resolves to A's right page, not B's controlled page.
|
||||
assert_eq!(actual.parent_page_for(1), Some((0, 1)));
|
||||
assert_eq!(actual.parents[0].pages[1].liquid, R::ReCenter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_and_round_trip() {
|
||||
use std::io::Read;
|
||||
|
||||
let baseline = import_idml(&fixture(None, None)).unwrap();
|
||||
for (literal, rule) in CASES {
|
||||
// Set native enums directly; the literal importer under test does not build this input.
|
||||
let mut source = baseline.clone();
|
||||
let pages = &mut Arc::make_mut(&mut source.spreads[0]).pages;
|
||||
pages[0].parent = None;
|
||||
pages[0].liquid = R::ControlledByParent;
|
||||
pages[1].liquid = rule;
|
||||
Arc::make_mut(&mut source.parents[0]).pages[1].liquid = R::ReCenter;
|
||||
Arc::make_mut(&mut source.parents[1]).pages[0].liquid = rule;
|
||||
source.check().unwrap();
|
||||
let bytes = export_idml(&source);
|
||||
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
for (directory, tag, spread, rules) in [
|
||||
("Spreads", "Spread", &source.spreads[0], ["UseMaster", literal]),
|
||||
("MasterSpreads", "MasterSpread", &source.parents[0], ["Off", "Recenter"]),
|
||||
("MasterSpreads", "MasterSpread", &source.parents[1], [literal, "Off"]),
|
||||
] {
|
||||
let path = format!("{directory}/{tag}_u{:x}.xml", spread.id.0);
|
||||
let mut part = Vec::new();
|
||||
archive.by_name(&path).unwrap().read_to_end(&mut part).unwrap();
|
||||
let root = xml::parse(&part).unwrap();
|
||||
let body = root.find(tag).unwrap();
|
||||
let pages: Vec<_> = body.find_all("Page").collect();
|
||||
assert_eq!(pages.len(), spread.pages.len(), "{path}");
|
||||
for (page, expected) in spread.pages.iter().zip(rules) {
|
||||
let id = format!("u{:x}", page.id.0);
|
||||
let matches: Vec<_> = pages.iter().filter(|p| p.get("Self") == Some(id.as_str())).collect();
|
||||
assert_eq!(matches.len(), 1, "{path}: {id}");
|
||||
assert_eq!(matches[0].get("LayoutRule"), Some(expected), "{path}: {id}");
|
||||
}
|
||||
}
|
||||
let actual = import_idml(&bytes).unwrap();
|
||||
assert_eq!((actual.spreads.len(), actual.parents.len()), (1, 2));
|
||||
assert_eq!(actual.spreads[0].pages[0].parent, None);
|
||||
assert_eq!(actual.spreads[0].pages[1].parent, Some(actual.parents[0].id));
|
||||
assert_eq!(actual.parent_page_for(1), Some((0, 1)));
|
||||
assert!(actual.parents.iter().flat_map(|sp| &sp.pages).all(|pg| pg.parent.is_none()));
|
||||
for (index, prefix, name, based_on) in [(0, "A", "Base", None), (1, "B", "Derived", Some(actual.parents[0].id))] {
|
||||
let info = actual.parents[index].parent.as_ref().unwrap();
|
||||
assert_eq!((&info.prefix[..], &info.name[..], info.based_on), (prefix, name, based_on));
|
||||
}
|
||||
for (got, want) in actual.spreads.iter().chain(&actual.parents).zip(source.spreads.iter().chain(&source.parents)) {
|
||||
assert_eq!(got.pages.len(), want.pages.len());
|
||||
for (got, want) in got.pages.iter().zip(&want.pages) {
|
||||
// IDs are allocated afresh; parent associations were checked explicitly above.
|
||||
let mut normalized = got.clone();
|
||||
normalized.id = want.id;
|
||||
normalized.parent = want.parent;
|
||||
assert_eq!(&normalized, want, "{literal}: all other Page fields, including the stored rule");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,22 @@ fn image(n: usize, uri: &str, contents: Option<&[u8]>) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// A graphic frame showing `pdf` (a `<PDF>` element) with the page `PageNumber` (1-based).
|
||||
fn pdf_frame(n: usize, x: f64, page: u32) -> String {
|
||||
format!(
|
||||
r#"<Rectangle Self="r{n}" ItemLayer="L1" ItemTransform="1 0 0 1 {x} 0" ContentType="GraphicType">
|
||||
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
|
||||
<PathPointType Anchor="0 0"/><PathPointType Anchor="0 30"/><PathPointType Anchor="40 30"/><PathPointType Anchor="40 0"/>
|
||||
</PathPointArray></GeometryPathType></PathGeometry></Properties>
|
||||
<PDF Self="im{n}" ItemTransform="1 0 0 1 {n} 0">
|
||||
<Properties><GraphicBounds Left="0" Top="0" Right="40" Bottom="30"/></Properties>
|
||||
<Link Self="lk{n}" LinkResourceURI="file:/art/insert.pdf" LinkResourceFormat="$ID/PDF" StoredState="Normal"/>
|
||||
<PDFAttribute PageNumber="{page}" PDFCrop="CropMedia" TransparentBackground="true"/>
|
||||
</PDF>
|
||||
</Rectangle>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn package(frames: &[String]) -> Vec<u8> {
|
||||
let spread = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -151,3 +167,27 @@ fn placed_svg_with_only_a_viewbox_keeps_its_viewbox_size() {
|
||||
let got: Vec<_> = gs.into_iter().map(|(_, size, x)| (size, x)).collect();
|
||||
assert_eq!(got, vec![((200.0, 100.0), -11.0), ((200.0, 100.0), -11.0), ((210.0, 100.0), -10.0)]);
|
||||
}
|
||||
|
||||
/// `PDFAttribute/@PageNumber` (1-based) is read into `Asset.page` (0-based), and the same file at
|
||||
/// different pages stays apart (#905).
|
||||
#[test]
|
||||
fn a_pdf_placement_reads_its_page_number() {
|
||||
let bytes = b"%PDF-1.7\n% fixture".to_vec();
|
||||
let reader = |p: &str| p.ends_with("insert.pdf").then(|| bytes.clone());
|
||||
let frames = vec![pdf_frame(1, 0.0, 2), pdf_frame(2, 100.0, 1), pdf_frame(3, 200.0, 2)];
|
||||
let d = import_idml_with(&package(&frames), &reader).unwrap();
|
||||
let p = placements(&d);
|
||||
let page = |id| d.assets.get(&id).map(|a| a.page);
|
||||
assert_eq!(page(p[0].0), Some(1), "PageNumber=\"2\" is the second page, stored 0-based");
|
||||
assert_eq!(page(p[1].0), Some(0), "PageNumber=\"1\" is the first page");
|
||||
assert_eq!(p[0].0, p[2].0, "the same file at the same page shares one asset");
|
||||
assert_ne!(p[0].0, p[1].0, "the same file at another page is another asset");
|
||||
|
||||
// Exported (linked or embedded), the page survives the round trip.
|
||||
for embed_images in [false, true] {
|
||||
let out = export_idml_with(&d, &ExportOptions { embed_images });
|
||||
let back = import_idml_with(&out, &reader).unwrap();
|
||||
let pages: Vec<_> = placements(&back).iter().map(|(id, _, _)| back.assets.get(id).map(|a| a.page)).collect();
|
||||
assert_eq!(pages, vec![Some(1), Some(0), Some(1)], "embed_images: {embed_images}");
|
||||
}
|
||||
}
|
||||
|
||||
+35
-20
@@ -149,6 +149,7 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions
|
||||
tags: Vec::new(),
|
||||
story_tags: HashMap::new(),
|
||||
tag_story: None,
|
||||
text_area: None,
|
||||
para_tags: HashMap::new(),
|
||||
};
|
||||
if ex.rgb_only {
|
||||
@@ -336,6 +337,7 @@ fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) ->
|
||||
tags: Vec::new(),
|
||||
story_tags: HashMap::new(),
|
||||
tag_story: None,
|
||||
text_area: None,
|
||||
para_tags: HashMap::new(),
|
||||
};
|
||||
let pairs = booklet_pairs(n, opts.kind);
|
||||
@@ -480,6 +482,9 @@ pub(crate) struct Exporter<'a> {
|
||||
/// While drawing a story's frame for the structure tree: the story (text runs are tagged
|
||||
/// per paragraph, everything else in the frame is an artifact).
|
||||
pub(crate) tag_story: Option<designcraft_doc::StoryId>,
|
||||
/// While drawing a frame's text: the frame's box in text space, which a gradient on the text
|
||||
/// spans (as InDesign lays it across the frame).
|
||||
pub(crate) text_area: Option<Rect>,
|
||||
/// Marked content of each (story, paragraph index).
|
||||
pub(crate) para_tags: HashMap<(designcraft_doc::StoryId, usize), Vec<Identifier>>,
|
||||
}
|
||||
@@ -936,8 +941,10 @@ impl Exporter<'_> {
|
||||
// Type on a path: the glyphs as outlines along the path.
|
||||
for (si, gp) in designcraft_compose::path_glyphs(&cs, ft, &bp, pt) {
|
||||
let st = &cs.styles[si as usize];
|
||||
if let (Some(c), Some(p)) = (self.swatch_color(&st.fill, st.fill_tint), to_path(&gp)) {
|
||||
s.set_fill(Some(Fill { paint: c.into(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
|
||||
// A gradient spans the path's box.
|
||||
let fill = designcraft_doc::Fill { swatch: st.fill.clone(), tint: st.fill_tint, ..designcraft_doc::Fill::none() };
|
||||
if let (Some(paint), Some(p)) = (self.fill_paint(&fill, bp.bounding_box()), to_path(&gp)) {
|
||||
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
|
||||
s.draw_path(&p);
|
||||
s.set_fill(None);
|
||||
}
|
||||
@@ -948,7 +955,9 @@ impl Exporter<'_> {
|
||||
if local != Affine::IDENTITY {
|
||||
s.push_transform(&tf(local));
|
||||
}
|
||||
self.text_area = (local.determinant().abs() > 1e-12).then(|| local.inverse().transform_rect_bbox(it.inner_bounds()));
|
||||
self.frame_text(s, &cs, ft, text);
|
||||
self.text_area = None;
|
||||
if local != Affine::IDENTITY {
|
||||
s.pop();
|
||||
}
|
||||
@@ -988,8 +997,14 @@ impl Exporter<'_> {
|
||||
}
|
||||
|
||||
/// A swatch fill (solid or gradient) in the item's inner space. `None` for [None]/unknown.
|
||||
fn fill_paint(&mut self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option<krilla::paint::Paint> {
|
||||
let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle);
|
||||
pub(crate) fn fill_paint(&mut self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option<krilla::paint::Paint> {
|
||||
self.fill_paint_in(fill, bounds, Affine::IDENTITY)
|
||||
}
|
||||
|
||||
/// [`Self::fill_paint`] for a gradient laid out in a space that `xf` maps to the current one
|
||||
/// (text runs: the frame's text space under the run's origin).
|
||||
pub(crate) fn fill_paint_in(&mut self, fill: &designcraft_doc::Fill, bounds: Rect, xf: Affine) -> Option<krilla::paint::Paint> {
|
||||
let (swatch, tint) = (fill.swatch.as_str(), fill.tint);
|
||||
if let Some(g) = designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch) {
|
||||
let kind = g.kind;
|
||||
let mut expanded = g.expanded_stops();
|
||||
@@ -1030,8 +1045,6 @@ impl Exporter<'_> {
|
||||
if stops.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let c = bounds.center();
|
||||
let v = fill.gradient_vector;
|
||||
return Some(match kind {
|
||||
GradientKind::Radial => {
|
||||
// A circle stretched into the object's ellipse, as on screen.
|
||||
@@ -1046,7 +1059,7 @@ impl Exporter<'_> {
|
||||
cx,
|
||||
cy,
|
||||
cr: rx as f32,
|
||||
transform: tf(stretch),
|
||||
transform: tf(xf * stretch),
|
||||
spread_method: SpreadMethod::Pad,
|
||||
stops,
|
||||
anti_alias: false,
|
||||
@@ -1054,19 +1067,13 @@ impl Exporter<'_> {
|
||||
.into()
|
||||
}
|
||||
_ => {
|
||||
let a = angle.unwrap_or(0.0).to_radians();
|
||||
let half = ((bounds.width() * a.cos().abs() + bounds.height() * a.sin().abs()) / 2.0).max(1e-3);
|
||||
let d = Vec2::new(a.cos(), -a.sin()) * half;
|
||||
let (p0, p1) = match v {
|
||||
Some([x0, y0, x1, y1]) => (designcraft_geom::Point::new(x0, y0), designcraft_geom::Point::new(x1, y1)),
|
||||
None => (c - d, c + d),
|
||||
};
|
||||
let (p0, p1) = fill.linear_axis(bounds);
|
||||
LinearGradient {
|
||||
x1: p0.x as f32,
|
||||
y1: p0.y as f32,
|
||||
x2: p1.x as f32,
|
||||
y2: p1.y as f32,
|
||||
transform: Transform::identity(),
|
||||
transform: tf(xf),
|
||||
spread_method: SpreadMethod::Pad,
|
||||
stops,
|
||||
anti_alias: false,
|
||||
@@ -1080,7 +1087,14 @@ impl Exporter<'_> {
|
||||
|
||||
fn stroke(&mut self, s: &mut Surface, it: &Item, bp: &BezPath, path: &Path) {
|
||||
let st = &it.stroke;
|
||||
let Some(c) = self.swatch_color(&st.swatch, st.tint) else { return };
|
||||
// A gradient swatch runs across the path's box, as on a fill.
|
||||
let paint: krilla::paint::Paint = if designcraft_color::swatch::resolve_gradient(&self.doc.swatches, &st.swatch).is_some() {
|
||||
let Some(p) = self.fill_paint(&st.as_fill(), bp.bounding_box()) else { return };
|
||||
p
|
||||
} else {
|
||||
let Some(c) = self.swatch_color(&st.swatch, st.tint) else { return };
|
||||
c.into()
|
||||
};
|
||||
let closed = it.path.is_closed();
|
||||
let mut cap = match st.cap {
|
||||
designcraft_doc::Cap::Butt => krilla::paint::LineCap::Butt,
|
||||
@@ -1101,7 +1115,7 @@ impl Exporter<'_> {
|
||||
s.draw_path(path);
|
||||
s.set_stroke(None);
|
||||
}
|
||||
s.set_fill(Some(Fill { paint: c.into(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
|
||||
s.set_fill(Some(Fill { paint: paint.clone(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
|
||||
s.draw_path(&op);
|
||||
s.set_fill(None);
|
||||
return;
|
||||
@@ -1153,7 +1167,7 @@ impl Exporter<'_> {
|
||||
s.draw_path(path);
|
||||
}
|
||||
s.set_stroke(Some(Stroke {
|
||||
paint: c.clone().into(),
|
||||
paint: paint.clone(),
|
||||
width: width as f32,
|
||||
miter_limit: st.miter_limit.max(1.0) as f32,
|
||||
line_cap: cap,
|
||||
@@ -1174,12 +1188,12 @@ impl Exporter<'_> {
|
||||
let Some(p) = to_path(&h.path) else { continue };
|
||||
match h.outline {
|
||||
Some(w) => {
|
||||
s.set_stroke(Some(Stroke { paint: c.clone().into(), width: w as f32, opacity: NormalizedF32::ONE, ..Default::default() }));
|
||||
s.set_stroke(Some(Stroke { paint: paint.clone(), width: w as f32, opacity: NormalizedF32::ONE, ..Default::default() }));
|
||||
s.draw_path(&p);
|
||||
s.set_stroke(None);
|
||||
}
|
||||
None => {
|
||||
s.set_fill(Some(krilla::paint::Fill { paint: c.clone().into(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
|
||||
s.set_fill(Some(krilla::paint::Fill { paint: paint.clone(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
|
||||
s.draw_path(&p);
|
||||
s.set_fill(None);
|
||||
}
|
||||
@@ -1620,6 +1634,7 @@ mod image_content_cache_tests {
|
||||
tags: Vec::new(),
|
||||
story_tags: HashMap::new(),
|
||||
tag_story: None,
|
||||
text_area: None,
|
||||
para_tags: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
+59
-15
@@ -16,13 +16,47 @@ use designcraft_fonts::{FontDb, FontFace};
|
||||
use designcraft_geom::kurbo::Shape as _;
|
||||
use designcraft_geom::{Affine, BezPath, Rect};
|
||||
use krilla::geom::Point;
|
||||
use krilla::paint::Stroke;
|
||||
use krilla::num::NormalizedF32;
|
||||
use krilla::paint::{Fill, FillRule, Paint, Stroke};
|
||||
use krilla::surface::Surface;
|
||||
use krilla::tagging::{Artifact, ArtifactType, ContentTag, SpanTag};
|
||||
use krilla::text::{Font, GlyphId, KrillaGlyph};
|
||||
|
||||
use crate::export::{Exporter, solid_fill, tf, to_path};
|
||||
|
||||
impl Exporter<'_> {
|
||||
/// A text run's paint: solid, or a gradient spanning the frame's box (`area`, text space),
|
||||
/// for glyphs drawn under `origin` (text space ← run space).
|
||||
fn text_paint(&mut self, swatch: &str, tint: f32, origin: Affine, area: Rect) -> Option<Paint> {
|
||||
if designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch).is_some() && origin.determinant().abs() > 1e-12 {
|
||||
let fill = designcraft_doc::Fill { swatch: swatch.into(), tint, ..designcraft_doc::Fill::none() };
|
||||
return self.fill_paint_in(&fill, self.text_area.unwrap_or(area), origin.inverse());
|
||||
}
|
||||
self.swatch_color(swatch, tint).map(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
/// The box a gradient on a cell's or a note's text spans: its lines, each padded by its ascent as
|
||||
/// the screen renderer pads them, so both lay the gradient out alike.
|
||||
fn lines_area(ft: &FrameText) -> Option<Rect> {
|
||||
ft.lines
|
||||
.iter()
|
||||
.filter_map(|l| {
|
||||
let first = l.glyphs.first()?;
|
||||
let x0 = l.glyphs.iter().map(|g| g.x).fold(first.x, f64::min);
|
||||
let x1 = l.glyphs.iter().map(|g| g.x + g.adv.max(0.0)).fold(l.end_x, f64::max);
|
||||
let pad = l.ascent.max(1.0);
|
||||
let bottom = l.drop_cap.map_or(l.baseline + l.descent.max(0.0), |dc| dc.rect.y1.max(l.baseline + l.descent.max(0.0)));
|
||||
Some(Rect::new(x0 - pad, l.baseline - l.ascent - pad, x1 + pad, bottom + pad))
|
||||
})
|
||||
.reduce(|a, b| a.union(b))
|
||||
}
|
||||
|
||||
/// An opaque nonzero fill with `paint`.
|
||||
fn paint_fill(paint: Paint) -> Fill {
|
||||
Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero }
|
||||
}
|
||||
|
||||
/// Private-use characters DesignCraft stores for markers (page numbers, section markers, breaks).
|
||||
fn is_marker(c: char) -> bool {
|
||||
('\u{E000}'..='\u{E1FF}').contains(&c)
|
||||
@@ -176,7 +210,9 @@ impl Exporter<'_> {
|
||||
for n in &ft.notes {
|
||||
if let Some(nft) = n.text.frames.first() {
|
||||
s.push_transform(&tf(Affine::translate(n.origin.to_vec2())));
|
||||
let area = std::mem::replace(&mut me.text_area, lines_area(nft));
|
||||
me.frame_text(s, &n.text, nft, &n.source);
|
||||
me.text_area = area;
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
@@ -204,7 +240,9 @@ impl Exporter<'_> {
|
||||
}
|
||||
} else if let Some(cft) = c.text.frames.first() {
|
||||
s.push_transform(&tf(Affine::translate(c.origin.to_vec2())));
|
||||
let area = std::mem::replace(&mut self.text_area, lines_area(cft));
|
||||
self.frame_text(s, &c.text, cft, &c.source);
|
||||
self.text_area = area;
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
@@ -288,17 +326,23 @@ impl Exporter<'_> {
|
||||
fn run(&mut self, s: &mut Surface, cs: &ComposedStory, glyphs: &[PlacedGlyph], baseline: f64, story: &str) {
|
||||
let g0 = &glyphs[0];
|
||||
let st = &cs.styles[g0.style as usize];
|
||||
let fill = self.swatch_color(&st.fill, st.fill_tint);
|
||||
let stroke =
|
||||
if st.stroke != designcraft_color::swatch::NONE && st.stroke_weight > 0.0 { self.swatch_color(&st.stroke, st.stroke_tint) } else { None };
|
||||
if fill.is_none() && stroke.is_none() {
|
||||
return;
|
||||
}
|
||||
let face = g0.face;
|
||||
let size = g0.sy * face.upem;
|
||||
let hs = g0.sx / g0.sy;
|
||||
let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -st.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
let origin = Affine::translate((g0.x, baseline + g0.y)) * skew * Affine::scale_non_uniform(hs, 1.0);
|
||||
// Outside a frame (no text area), a gradient spans the run.
|
||||
let end = glyphs.last().map_or(g0.x, |g| g.x + g.adv);
|
||||
let run_area = Rect::new(g0.x, baseline - size, end.max(g0.x + 1.0), baseline + size * 0.25);
|
||||
let fill = self.text_paint(&st.fill, st.fill_tint, origin, run_area);
|
||||
let stroke = if st.stroke != designcraft_color::swatch::NONE && st.stroke_weight > 0.0 {
|
||||
self.text_paint(&st.stroke, st.stroke_tint, origin, run_area)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if fill.is_none() && stroke.is_none() {
|
||||
return;
|
||||
}
|
||||
// Stroke behind fill: a double-width stroke first, as outlines so the text is not
|
||||
// duplicated, then the fill over its inner half. With no fill, the stroke is clipped
|
||||
// to the outside of the glyphs instead.
|
||||
@@ -342,8 +386,8 @@ impl Exporter<'_> {
|
||||
origin: Affine,
|
||||
size: f64,
|
||||
hs: f64,
|
||||
fill: Option<krilla::color::Color>,
|
||||
stroke: Option<krilla::color::Color>,
|
||||
fill: Option<Paint>,
|
||||
stroke: Option<Paint>,
|
||||
stroke_weight: f64,
|
||||
) {
|
||||
let (text, ranges) = self.run_text(glyphs, story);
|
||||
@@ -358,8 +402,8 @@ impl Exporter<'_> {
|
||||
})
|
||||
.collect();
|
||||
s.push_transform(&tf(origin));
|
||||
s.set_fill(fill.map(|c| solid_fill(c, 1.0)));
|
||||
s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: stroke_weight as f32, ..Default::default() }));
|
||||
s.set_fill(fill.map(paint_fill));
|
||||
s.set_stroke(stroke.map(|paint| Stroke { paint, width: stroke_weight as f32, ..Default::default() }));
|
||||
s.draw_glyphs(Point::from_xy(0.0, 0.0), &kg, font, &text, size as f32, false);
|
||||
s.set_fill(None);
|
||||
s.set_stroke(None);
|
||||
@@ -372,14 +416,14 @@ impl Exporter<'_> {
|
||||
s: &mut Surface,
|
||||
outlines: &BezPath,
|
||||
origin: Affine,
|
||||
fill: Option<krilla::color::Color>,
|
||||
stroke: Option<krilla::color::Color>,
|
||||
fill: Option<Paint>,
|
||||
stroke: Option<Paint>,
|
||||
stroke_weight: f64,
|
||||
) {
|
||||
let Some(p) = to_path(outlines) else { return };
|
||||
s.push_transform(&tf(origin));
|
||||
s.set_fill(fill.map(|c| solid_fill(c, 1.0)));
|
||||
s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: stroke_weight as f32, ..Default::default() }));
|
||||
s.set_fill(fill.map(paint_fill));
|
||||
s.set_stroke(stroke.map(|paint| Stroke { paint, width: stroke_weight as f32, ..Default::default() }));
|
||||
s.draw_path(&p);
|
||||
s.set_fill(None);
|
||||
s.set_stroke(None);
|
||||
|
||||
+17
-10
@@ -677,8 +677,17 @@ impl Renderer {
|
||||
}
|
||||
|
||||
fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine) {
|
||||
self.draw_stroke_painted(ctx, f, it, bp, xf);
|
||||
ctx.reset_paint_transform();
|
||||
}
|
||||
|
||||
/// The stroke, in its colour or gradient (a gradient runs across the path's box, as a fill's).
|
||||
fn draw_stroke_painted(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine) {
|
||||
let st = &it.stroke;
|
||||
let Some(c) = f.doc.resolve_color(&st.swatch, st.tint) else { return };
|
||||
let (paint, bounds) = (st.as_fill(), bp.bounding_box());
|
||||
if f.doc.resolve_color(&st.swatch, st.tint).is_none() && designcraft_color::swatch::resolve_gradient(&f.doc.swatches, &st.swatch).is_none() {
|
||||
return;
|
||||
}
|
||||
let mut stroke = kurbo::Stroke::new(st.weight)
|
||||
.with_caps(match st.cap {
|
||||
designcraft_doc::Cap::Butt => kurbo::Cap::Butt,
|
||||
@@ -698,7 +707,9 @@ impl Renderer {
|
||||
_ => {}
|
||||
}
|
||||
ctx.set_transform(f.view * xf);
|
||||
ctx.set_paint(color_of(&c, 1.0));
|
||||
if !set_fill_paint(ctx, f.doc, &paint, bounds) {
|
||||
return;
|
||||
}
|
||||
let closed = it.path.is_closed();
|
||||
// Arrowheads: the path is shortened under them; they're drawn after it.
|
||||
let arrows = designcraft_doc::arrow::apply(bp, st, closed);
|
||||
@@ -725,7 +736,7 @@ impl Renderer {
|
||||
if op {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
ctx.set_paint(color_of(&c, 1.0));
|
||||
set_fill_paint(ctx, f.doc, &paint, bounds);
|
||||
}
|
||||
// Stripes, wavy and hash strokes are fills (along the path's centre line).
|
||||
if let Some(o) = kind.outline(bp, st.weight, 0.05 * f.px) {
|
||||
@@ -982,7 +993,7 @@ pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color {
|
||||
|
||||
/// Set a swatch fill (solid or gradient) as the paint. Returns false for [None]/unknown.
|
||||
fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, fill: &designcraft_doc::Fill, bounds: Rect) -> bool {
|
||||
let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle);
|
||||
let (swatch, tint) = (fill.swatch.as_str(), fill.tint);
|
||||
if let Some(g) = designcraft_color::swatch::resolve_gradient(&doc.swatches, swatch) {
|
||||
// Sampled so process colours blend as inks, as InDesign shows them (and as the PDF's CMYK
|
||||
// shading prints).
|
||||
@@ -990,7 +1001,6 @@ fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, fill: &designcraft_do
|
||||
if stops.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let c = bounds.center();
|
||||
let grad = match (g.kind, fill.gradient_vector) {
|
||||
// Radial: a circle stretched into the object's ellipse (the paint transform).
|
||||
(designcraft_color::GradientKind::Radial, _) => {
|
||||
@@ -999,12 +1009,9 @@ fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, fill: &designcraft_do
|
||||
ctx.set_paint_transform(Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(1.0, ry / rx) * Affine::translate(-c.to_vec2()));
|
||||
peniko::Gradient::new_radial(c, rx as f32).with_stops(stops.as_slice())
|
||||
}
|
||||
(_, Some([x0, y0, x1, y1])) => peniko::Gradient::new_linear((x0, y0), (x1, y1)).with_stops(stops.as_slice()),
|
||||
_ => {
|
||||
let a = angle.unwrap_or(0.0).to_radians();
|
||||
let half = (bounds.width() * a.cos().abs() + bounds.height() * a.sin().abs()) / 2.0;
|
||||
let d = Vec2::new(a.cos(), -a.sin()) * half;
|
||||
peniko::Gradient::new_linear(c - d, c + d).with_stops(stops.as_slice())
|
||||
let (a, b) = fill.linear_axis(bounds);
|
||||
peniko::Gradient::new_linear(a, b).with_stops(stops.as_slice())
|
||||
}
|
||||
};
|
||||
ctx.set_paint(grad);
|
||||
|
||||
+76
-25
@@ -19,6 +19,45 @@ use vello_cpu::peniko;
|
||||
|
||||
use crate::{Frame, Renderer, color_of, rect_overlaps, screen_color};
|
||||
|
||||
/// A text colour: solid, or a gradient swatch laid across the frame (InDesign spans a gradient on
|
||||
/// text across its frame's box).
|
||||
#[derive(Clone)]
|
||||
enum TextPaint {
|
||||
Solid(peniko::Color),
|
||||
Gradient(designcraft_doc::Fill),
|
||||
}
|
||||
|
||||
impl TextPaint {
|
||||
fn of(doc: &designcraft_doc::Document, swatch: &str, tint: f32) -> Option<Self> {
|
||||
if designcraft_color::swatch::resolve_gradient(&doc.swatches, swatch).is_some() {
|
||||
return Some(TextPaint::Gradient(designcraft_doc::Fill { swatch: swatch.into(), tint, ..designcraft_doc::Fill::none() }));
|
||||
}
|
||||
doc.resolve_color(swatch, tint).map(|c| TextPaint::Solid(color_of(&c, 1.0)))
|
||||
}
|
||||
|
||||
/// Make this the context's paint; a gradient runs across `area` (text space).
|
||||
fn set(&self, ctx: &mut RenderContext, doc: &designcraft_doc::Document, area: Rect) {
|
||||
match self {
|
||||
TextPaint::Solid(c) => ctx.set_paint(*c),
|
||||
TextPaint::Gradient(fill) => {
|
||||
crate::set_fill_paint(ctx, doc, fill, area);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The box a gradient on a frame's text spans: the frame's, in text space (the union of its lines
|
||||
/// when the frame is gone).
|
||||
fn text_area(doc: &designcraft_doc::Document, ft: &FrameText) -> Rect {
|
||||
if let Some(it) = doc.item(ft.frame) {
|
||||
let local = doc.text_local(it);
|
||||
if local.determinant().abs() > 1e-12 {
|
||||
return local.inverse().transform_rect_bbox(it.inner_bounds());
|
||||
}
|
||||
}
|
||||
ft.lines.iter().filter_map(line_box).reduce(|a, b| a.union(b)).unwrap_or(Rect::new(0.0, 0.0, 1.0, 1.0))
|
||||
}
|
||||
|
||||
/// Cached outlines of one composed line.
|
||||
pub(crate) struct LineGlyphs {
|
||||
/// Frame-space bounds of everything drawn for the line (empty lines: `None`).
|
||||
@@ -351,13 +390,16 @@ impl Renderer {
|
||||
ctx.set_transform(m);
|
||||
if !shown.is_empty() {
|
||||
let fg = self.glyphs.get(cs, ft);
|
||||
// Resolve each style's paints once per frame.
|
||||
let mut fills: Vec<Option<Option<peniko::Color>>> = vec![None; cs.styles.len()];
|
||||
let mut fill_of = |si: u32| -> Option<peniko::Color> {
|
||||
*fills[si as usize].get_or_insert_with(|| {
|
||||
let st = &cs.styles[si as usize];
|
||||
doc.resolve_color(&st.fill, st.fill_tint).map(|c| color_of(&c, 1.0))
|
||||
})
|
||||
// Resolve each style's paints once per frame; gradients span the frame.
|
||||
let area = text_area(doc, ft);
|
||||
let mut fills: Vec<Option<Option<TextPaint>>> = vec![None; cs.styles.len()];
|
||||
let mut fill_of = |si: u32| -> Option<TextPaint> {
|
||||
fills[si as usize]
|
||||
.get_or_insert_with(|| {
|
||||
let st = &cs.styles[si as usize];
|
||||
TextPaint::of(doc, &st.fill, st.fill_tint)
|
||||
})
|
||||
.clone()
|
||||
};
|
||||
shown.retain(|&i| fg.lines[i].bounds.is_some_and(|b| rect_overlaps(b, vis)));
|
||||
// Paint all underlines before any glyphs: skewed runs and tightly spaced lines
|
||||
@@ -376,7 +418,7 @@ impl Renderer {
|
||||
for (si, bp) in &lg.runs {
|
||||
let st = &cs.styles[*si as usize];
|
||||
let fill = fill_of(*si).map(|c| (c, crate::overprints(f, &st.fill, st.fill_tint, false)));
|
||||
paint_glyphs(ctx, bp, fill, stroke_of(doc, st), st.stroke_weight, st.stroke_behind_fill);
|
||||
paint_glyphs(ctx, doc, area, bp, fill, stroke_of(doc, st), st.stroke_weight, st.stroke_behind_fill);
|
||||
}
|
||||
}
|
||||
for &i in &shown {
|
||||
@@ -450,32 +492,37 @@ impl Renderer {
|
||||
|
||||
/// Kurbo stroke for a table edge.
|
||||
/// A run's stroke colour, if it has one.
|
||||
fn stroke_of(doc: &designcraft_doc::Document, st: &designcraft_compose::RunStyle) -> Option<peniko::Color> {
|
||||
fn stroke_of(doc: &designcraft_doc::Document, st: &designcraft_compose::RunStyle) -> Option<TextPaint> {
|
||||
if st.stroke == designcraft_color::swatch::NONE {
|
||||
return None;
|
||||
}
|
||||
doc.resolve_color(&st.stroke, st.stroke_tint).map(|c| color_of(&c, 1.0))
|
||||
TextPaint::of(doc, &st.stroke, st.stroke_tint)
|
||||
}
|
||||
|
||||
/// Paint glyph outlines: the fill (multiplied when it overprints), then the stroke over it. With
|
||||
/// stroke behind fill a double-width stroke goes first and the fill covers its inner half; with
|
||||
/// no fill to cover it, the glyph shapes are cut out of the stroke instead.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paint_glyphs(
|
||||
ctx: &mut RenderContext,
|
||||
doc: &designcraft_doc::Document,
|
||||
area: Rect,
|
||||
bp: &BezPath,
|
||||
fill: Option<(peniko::Color, bool)>,
|
||||
stroke: Option<peniko::Color>,
|
||||
fill: Option<(TextPaint, bool)>,
|
||||
stroke: Option<TextPaint>,
|
||||
weight: f64,
|
||||
behind: bool,
|
||||
) {
|
||||
// A gradient paint runs across `area`; reset its transform after each use.
|
||||
let paint_fill = |ctx: &mut RenderContext| {
|
||||
let Some((c, overprint)) = fill else { return };
|
||||
if overprint {
|
||||
let Some((c, overprint)) = &fill else { return };
|
||||
if *overprint {
|
||||
ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Multiply, peniko::Compose::SrcOver)), None, None, None);
|
||||
}
|
||||
ctx.set_paint(c);
|
||||
c.set(ctx, doc, area);
|
||||
ctx.fill_path(bp);
|
||||
if overprint {
|
||||
ctx.reset_paint_transform();
|
||||
if *overprint {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
};
|
||||
@@ -483,21 +530,23 @@ fn paint_glyphs(
|
||||
paint_fill(ctx);
|
||||
return;
|
||||
};
|
||||
let paint_stroke = |ctx: &mut RenderContext, width: f64| {
|
||||
sc.set(ctx, doc, area);
|
||||
ctx.set_stroke(kurbo::Stroke::new(width));
|
||||
ctx.stroke_path(bp);
|
||||
ctx.reset_paint_transform();
|
||||
};
|
||||
if !behind {
|
||||
paint_fill(ctx);
|
||||
ctx.set_paint(sc);
|
||||
ctx.set_stroke(kurbo::Stroke::new(weight));
|
||||
ctx.stroke_path(bp);
|
||||
paint_stroke(ctx, weight);
|
||||
return;
|
||||
}
|
||||
ctx.set_paint(sc);
|
||||
ctx.set_stroke(kurbo::Stroke::new(weight * 2.0));
|
||||
if fill.is_some() {
|
||||
ctx.stroke_path(bp);
|
||||
paint_stroke(ctx, weight * 2.0);
|
||||
paint_fill(ctx);
|
||||
} else {
|
||||
ctx.push_layer(None, None, None, None, None);
|
||||
ctx.stroke_path(bp);
|
||||
paint_stroke(ctx, weight * 2.0);
|
||||
ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut)), None, None, None);
|
||||
ctx.fill_path(bp);
|
||||
ctx.pop_layer();
|
||||
@@ -531,10 +580,12 @@ impl Renderer {
|
||||
) {
|
||||
let doc = f.doc;
|
||||
ctx.set_transform(f.view * xf);
|
||||
// A gradient on type on a path spans the path's box.
|
||||
let area = path.bounding_box();
|
||||
for (si, bp) in designcraft_compose::path_glyphs(cs, ft, path, pt) {
|
||||
let st = &cs.styles[si as usize];
|
||||
let fill = doc.resolve_color(&st.fill, st.fill_tint).map(|c| (color_of(&c, 1.0), false));
|
||||
paint_glyphs(ctx, &bp, fill, stroke_of(doc, st), st.stroke_weight, st.stroke_behind_fill);
|
||||
let fill = TextPaint::of(doc, &st.fill, st.fill_tint).map(|c| (c, false));
|
||||
paint_glyphs(ctx, doc, area, &bp, fill, stroke_of(doc, st), st.stroke_weight, st.stroke_behind_fill);
|
||||
self.stats.glyphs += 1;
|
||||
}
|
||||
}
|
||||
|
||||
+111
-18
@@ -1057,7 +1057,11 @@ fn draw_text_selection(
|
||||
cells: Option<designcraft_doc::TableSel>,
|
||||
) {
|
||||
let cs = app.session.cache.get(doc, ts.story, None);
|
||||
let range = ts.range();
|
||||
// Text an input method is composing at the caret is marked like it is in every Mac text
|
||||
// field: a yellow box behind the glyphs.
|
||||
let composing = app.session.composition().filter(|c| c.0 == ts.story && c.1 == ts.cell && ts.is_caret()).map(|c| c.2);
|
||||
let range = composing.clone().unwrap_or_else(|| ts.range());
|
||||
let tint = if composing.is_some() { COMPOSING_FILL.gamma_multiply(0.7) } else { Color32::from_rgba_unmultiplied(80, 140, 255, 110) };
|
||||
// Selected table cells: tint every selected cell.
|
||||
if let Some(sel) = cells.filter(|c| c.story == ts.story) {
|
||||
let selected = doc.story(sel.story).and_then(|s| s.tables.get(&sel.table)).map(|t| t.selected_owners(sel.range)).unwrap_or_default();
|
||||
@@ -1110,7 +1114,7 @@ fn draw_text_selection(
|
||||
let (a, b) = (x0 + (x1 - x0) * k as f64 / n as f64, x0 + (x1 - x0) * (k + 1) as f64 / n as f64);
|
||||
let poly =
|
||||
[Point::new(a, y0), Point::new(b, y0), Point::new(b, y1), Point::new(a, y1)].map(|p| xf.to_screen(m * map.to_path(p)));
|
||||
painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE));
|
||||
painter.add(egui::Shape::convex_polygon(poly.to_vec(), tint, Stroke::NONE));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1146,7 +1150,7 @@ fn draw_text_selection(
|
||||
}
|
||||
}
|
||||
if !runs.is_empty() {
|
||||
draw_inverse_highlight(app, painter, xf, doc, &cs, ts.story, &runs);
|
||||
draw_inverse_highlight(app, painter, xf, doc, &cs, ts.story, &runs, composing.is_some());
|
||||
}
|
||||
if ts.is_caret()
|
||||
&& let Some((fi, x, bl, asc, desc)) = compose::caret(&cs, ts.focus)
|
||||
@@ -1154,22 +1158,38 @@ fn draw_text_selection(
|
||||
&& let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame))
|
||||
{
|
||||
let m = a * doc.text_xf(it);
|
||||
// Type on a path: the caret stands across the path where it is along it.
|
||||
let on = |p: Point| compose::PathTextMap::new(it, ft).map_or(p, |map| map.to_path(p));
|
||||
let p0 = xf.to_screen(m * on(Point::new(x, bl - asc)));
|
||||
let p1 = xf.to_screen(m * on(Point::new(x, bl + desc)));
|
||||
let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0;
|
||||
if blink {
|
||||
// Type on a path: the caret stands across the path where it is along it.
|
||||
let on = |p: Point| compose::PathTextMap::new(it, ft).map_or(p, |map| map.to_path(p));
|
||||
let p0 = xf.to_screen(m * on(Point::new(x, bl - asc)));
|
||||
let p1 = xf.to_screen(m * on(Point::new(x, bl + desc)));
|
||||
painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK));
|
||||
}
|
||||
remember_ime_caret(painter, p0, p1);
|
||||
painter.ctx().request_repaint_after(std::time::Duration::from_millis(330));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the text caret stood on screen last frame: the OS puts its input-method window there.
|
||||
fn ime_caret_id() -> egui::Id {
|
||||
egui::Id::new("canvas_ime_caret")
|
||||
}
|
||||
|
||||
/// The input method is composing text (sent to `text.compose`) that it hasn't committed.
|
||||
fn ime_composing_id() -> egui::Id {
|
||||
egui::Id::new("canvas_ime_composing")
|
||||
}
|
||||
|
||||
/// Remembers the caret at `p0`–`p1` for the input method's window.
|
||||
fn remember_ime_caret(painter: &egui::Painter, p0: egui::Pos2, p1: egui::Pos2) {
|
||||
painter.ctx().data_mut(|d| d.insert_temp(ime_caret_id(), egui::Rect::from_two_pos(p0, p1)));
|
||||
}
|
||||
|
||||
/// Caret / text highlight inside a table cell (the cell's lines are offset by the cell origin).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_cell_selection(
|
||||
_app: &DesignApp,
|
||||
app: &DesignApp,
|
||||
painter: &egui::Painter,
|
||||
xf: &Xf,
|
||||
doc: &Document,
|
||||
@@ -1187,7 +1207,9 @@ fn draw_cell_selection(
|
||||
let Some(ft) = cs.frames.get(fi) else { return };
|
||||
let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { return };
|
||||
let m = a * doc.text_xf(it) * designcraft_geom::Affine::translate(origin.to_vec2());
|
||||
let range = ts.range();
|
||||
let composing = app.session.composition().filter(|c| c.0 == ts.story && c.1 == ts.cell && ts.is_caret()).map(|c| c.2);
|
||||
let range = composing.clone().unwrap_or_else(|| ts.range());
|
||||
let tint = if composing.is_some() { COMPOSING_FILL.gamma_multiply(0.6) } else { Color32::from_rgba_unmultiplied(80, 140, 255, 110) };
|
||||
if !range.is_empty()
|
||||
&& let Some(cft) = text.frames.first()
|
||||
{
|
||||
@@ -1200,7 +1222,7 @@ fn draw_cell_selection(
|
||||
let past = e == l.range.end && range.end > l.range.end;
|
||||
for q in compose::highlight_quads(l, s, e, past) {
|
||||
let poly = q.map(|p| xf.to_screen(m * p));
|
||||
painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE));
|
||||
painter.add(egui::Shape::convex_polygon(poly.to_vec(), tint, Stroke::NONE));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1211,20 +1233,25 @@ fn draw_cell_selection(
|
||||
}
|
||||
{
|
||||
let m = a * doc.text_xf(it);
|
||||
let (p0, p1) = (xf.to_screen(m * Point::new(x, bl - asc)), xf.to_screen(m * Point::new(x, bl + desc)));
|
||||
let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0;
|
||||
if blink {
|
||||
painter.line_segment(
|
||||
[xf.to_screen(m * Point::new(x, bl - asc)), xf.to_screen(m * Point::new(x, bl + desc))],
|
||||
Stroke::new(1.0, Color32::BLACK),
|
||||
);
|
||||
painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK));
|
||||
}
|
||||
remember_ime_caret(painter, p0, p1);
|
||||
painter.ctx().request_repaint_after(std::time::Duration::from_millis(330));
|
||||
}
|
||||
}
|
||||
|
||||
/// The box behind text an input method is composing.
|
||||
const COMPOSING_FILL: Color32 = Color32::from_rgb(255, 247, 153);
|
||||
|
||||
/// InDesign draws selected text inverted: a black highlight with the selected glyphs in the
|
||||
/// inverse of their colour (white for black text). The highlight and glyph outlines are
|
||||
/// rasterised with `vello_cpu` into a small screen-space texture over the selection.
|
||||
/// `composing`: the text is an input method's, still being composed: a yellow box with the
|
||||
/// glyphs in their own colour.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_inverse_highlight(
|
||||
app: &DesignApp,
|
||||
painter: &egui::Painter,
|
||||
@@ -1233,6 +1260,7 @@ fn draw_inverse_highlight(
|
||||
cs: &compose::ComposedStory,
|
||||
story: designcraft_doc::StoryId,
|
||||
runs: &[(usize, Affine, Vec<[Point; 4]>, Vec<(usize, usize)>)],
|
||||
composing: bool,
|
||||
) {
|
||||
use designcraft_render::vello_cpu::{self, kurbo, peniko};
|
||||
let ctx = painter.ctx();
|
||||
@@ -1259,7 +1287,7 @@ fn draw_inverse_highlight(
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let key = egui::Id::new(("inverse_sel", st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, story.0))
|
||||
.with((runs.iter().map(|r| r.2.len() + r.3.len() * 7919).sum::<usize>(), w, h))
|
||||
.with((bbox.min.x.to_bits(), bbox.min.y.to_bits(), xf.zoom.to_bits(), st.selection.text.map(|t| (t.anchor, t.focus))));
|
||||
.with((bbox.min.x.to_bits(), bbox.min.y.to_bits(), xf.zoom.to_bits(), st.selection.text.map(|t| (t.anchor, t.focus)), composing));
|
||||
let cache_id = egui::Id::new("inverse_sel_tex");
|
||||
let cached: Option<(egui::Id, egui::TextureHandle)> = ctx.data(|d| d.get_temp(cache_id));
|
||||
let tex = match cached {
|
||||
@@ -1267,7 +1295,8 @@ fn draw_inverse_highlight(
|
||||
_ => {
|
||||
let view = xf.affine(ppp as f64, bbox.min);
|
||||
let mut rc = vello_cpu::RenderContext::new_with(w as u16, h as u16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
rc.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255));
|
||||
let [br, bg, bb, _] = if composing { COMPOSING_FILL.to_array() } else { [0, 0, 0, 255] };
|
||||
rc.set_paint(peniko::Color::from_rgba8(br, bg, bb, 255));
|
||||
for (_, m, quads, _) in runs {
|
||||
rc.set_transform(view * *m);
|
||||
for q in quads {
|
||||
@@ -1288,8 +1317,12 @@ fn draw_inverse_highlight(
|
||||
let Some(g) = l.glyphs.get(gi) else { continue };
|
||||
let Some(style) = cs.styles.get(g.style as usize) else { continue };
|
||||
let Some(c) = doc.resolve_color(&style.fill, style.fill_tint) else { continue };
|
||||
let [r, gg, b, _] = c.to_rgba8(1.0);
|
||||
rc.set_paint(peniko::Color::from_rgba8(255 - r, 255 - gg, 255 - b, 255));
|
||||
let [r, gg, b, a] = c.to_rgba8(1.0);
|
||||
if composing {
|
||||
rc.set_paint(peniko::Color::from_rgba8(r, gg, b, a));
|
||||
} else {
|
||||
rc.set_paint(peniko::Color::from_rgba8(255 - r, 255 - gg, 255 - b, 255));
|
||||
}
|
||||
let outline = db.outline(&g.face, g.gid);
|
||||
let skew =
|
||||
if style.skew != 0.0 { Affine::new([1.0, 0.0, -style.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
@@ -1677,12 +1710,41 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
|
||||
if app.pane != app.focus_pane || (ui.ctx().text_edit_focused() && !resp.has_focus()) {
|
||||
return;
|
||||
}
|
||||
// The engine drops composed text when something else edits or the caret moves; the input
|
||||
// method must forget it too, or the next key would finish an accent that is gone.
|
||||
let composing = ui.data(|d| d.get_temp::<bool>(ime_composing_id())).unwrap_or(false);
|
||||
let interrupt = composing && app.session.composition().is_none();
|
||||
if interrupt || !wants_text {
|
||||
ui.data_mut(|d| d.remove::<bool>(ime_composing_id()));
|
||||
}
|
||||
if wants_text {
|
||||
// Turn the OS input method on: macOS composes dead keys (⌥E then I types í) and
|
||||
// Japanese/Chinese input only for a window that asks for it. Without it ⌥E typed "e".
|
||||
let cursor_rect = ui.data(|d| d.get_temp(ime_caret_id())).unwrap_or(egui::Rect::from_min_size(rect.min, egui::Vec2::ZERO));
|
||||
ui.ctx().output_mut(|o| {
|
||||
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect, cursor_rect, should_interrupt_composition: interrupt });
|
||||
});
|
||||
}
|
||||
let evs = ui.input(|i| i.events.clone());
|
||||
for e in evs {
|
||||
match e {
|
||||
egui::Event::Text(t) if wants_text => {
|
||||
let _ = app.run("text.insert", json!({"text": t}));
|
||||
}
|
||||
// What is composed shows in the story, in the text's own format, until it commits.
|
||||
egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) if wants_text => {
|
||||
let on = !text.is_empty();
|
||||
if app.run("text.compose", json!({"text": text})).is_ok() {
|
||||
ui.data_mut(|d| d.insert_temp(ime_composing_id(), on));
|
||||
}
|
||||
}
|
||||
egui::Event::Ime(egui::ImeEvent::Commit(t)) if wants_text => {
|
||||
ui.data_mut(|d| d.remove::<bool>(ime_composing_id()));
|
||||
let _ = app.run("text.compose", json!({"text": ""}));
|
||||
if !t.is_empty() {
|
||||
let _ = app.run("text.insert", json!({"text": t}));
|
||||
}
|
||||
}
|
||||
egui::Event::Paste(t) if wants_text => {
|
||||
// Formatted when the system clipboard still holds what was copied here;
|
||||
// Shift (⇧⌘V) pastes without formatting.
|
||||
@@ -2355,6 +2417,37 @@ mod tests {
|
||||
assert!(selected(&h).is_empty(), "three clicks half a second apart leave a caret: {:?}", selected(&h));
|
||||
}
|
||||
|
||||
/// ⌥E then I types "í" on a Mac: the input method composes the dead key's accent, which
|
||||
/// it does only for a window that turned it on. The canvas never did, so ⌥E typed "e".
|
||||
#[test]
|
||||
fn dead_keys_compose_through_the_input_method() {
|
||||
let (mut h, id) = typing("T");
|
||||
h.state_mut().run("text.placeCaret", json!({"frame": id, "point": [390.0, 80.0]})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert!(h.output().platform_output.ime.is_some(), "a text caret turns the input method on");
|
||||
h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: "´".into(), active_range_chars: None }));
|
||||
h.run_steps(2);
|
||||
let text = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.stories.values().map(|s| s.text.clone()).collect::<String>();
|
||||
assert_eq!(text(&h), "T´", "the accent being composed shows in the story, in its format");
|
||||
assert!(h.state().session.composition().is_some(), "and is marked as composed, not typed");
|
||||
h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: String::new(), active_range_chars: None }));
|
||||
h.event(egui::Event::Ime(egui::ImeEvent::Commit("í".into())));
|
||||
h.run_steps(2);
|
||||
assert_eq!(text(&h), "Tí");
|
||||
assert!(h.state().session.composition().is_none());
|
||||
// Moving the caret mid-composition drops the accent and tells the input method so.
|
||||
h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: "˜".into(), active_range_chars: None }));
|
||||
h.run_steps(2);
|
||||
h.state_mut().run("text.move", json!({"dir": "left"})).unwrap();
|
||||
h.step();
|
||||
assert_eq!(text(&h), "Tí");
|
||||
assert!(h.output().platform_output.ime.as_ref().is_some_and(|i| i.should_interrupt_composition));
|
||||
// Another tool takes no text: the input method is off.
|
||||
h.state_mut().run("tool.select", json!({"tool": "selection"})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert!(h.output().platform_output.ime.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_show_their_own_cursors_over_the_page() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
|
||||
@@ -160,6 +160,16 @@ const TABLE: &[(&str, [&str; 5])] = &[
|
||||
("Open Path", ["Pfad öffnen", "Ouvrir le tracé", "Abrir trazado", "パスをオープン", "开放路径"]),
|
||||
("Plain", ["Einfach", "Simple", "Normal", "プレーン", "普通"]),
|
||||
("Symmetrical", ["Symmetrisch", "Symétrique", "Simétrico", "対称", "对称"]),
|
||||
(
|
||||
"The stroke isn't a gradient.",
|
||||
[
|
||||
"Die Kontur ist kein Verlauf.",
|
||||
"Le contour n’est pas un dégradé.",
|
||||
"El trazo no es un degradado.",
|
||||
"線はグラデーションではありません。",
|
||||
"描边不是渐变。",
|
||||
],
|
||||
),
|
||||
("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]),
|
||||
("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]),
|
||||
("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]),
|
||||
|
||||
@@ -76,6 +76,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Open Path", "فتح المسار"),
|
||||
("Plain", "عادي"),
|
||||
("Symmetrical", "متماثل"),
|
||||
("The stroke isn't a gradient.", "الحد ليس تدرجًا."),
|
||||
("Reverse Path", "عكس المسار"),
|
||||
("Set Anchor", "تعيين نقطة الارتساء"),
|
||||
("Clear", "مسح"),
|
||||
|
||||
@@ -74,6 +74,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Open Path", "Apri tracciato"),
|
||||
("Plain", "Semplice"),
|
||||
("Symmetrical", "Simmetrico"),
|
||||
("The stroke isn't a gradient.", "La traccia non è una sfumatura."),
|
||||
("Reverse Path", "Inverti tracciato"),
|
||||
("Set Anchor", "Imposta ancoraggio"),
|
||||
("Clear", "Cancella"),
|
||||
|
||||
@@ -70,6 +70,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Join Path", "パスを連結"),
|
||||
("Plain", "プレーン"),
|
||||
("Symmetrical", "対称"),
|
||||
("The stroke isn't a gradient.", "線はグラデーションではありません。"),
|
||||
("Reverse Path", "パスの方向反転"),
|
||||
("Set Anchor", "アンカーポイントを設定"),
|
||||
("Clear", "消去"),
|
||||
|
||||
@@ -82,6 +82,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Plain", "Simples"),
|
||||
("Smooth", "Suave"),
|
||||
("Symmetrical", "Simétrico"),
|
||||
("The stroke isn't a gradient.", "O traçado não é um gradiente."),
|
||||
("Reverse Path", "Inverter demarcador"),
|
||||
("Set Anchor", "Definir ponto de ancoragem"),
|
||||
("Clear", "Limpar"),
|
||||
|
||||
@@ -77,6 +77,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Open Path", "Розімкнути контур"),
|
||||
("Plain", "Простий"),
|
||||
("Symmetrical", "Симетричний"),
|
||||
("The stroke isn't a gradient.", "Обведення не є градієнтом."),
|
||||
("Reverse Path", "Розвернути контур"),
|
||||
("Set Anchor", "Задати прив’язку"),
|
||||
("Clear", "Очистити"),
|
||||
|
||||
@@ -520,11 +520,21 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
));
|
||||
return;
|
||||
};
|
||||
let fill = it.fill.clone();
|
||||
// The fill's gradient, or the stroke's when the toolbox's Stroke box is in front (as InDesign's
|
||||
// Gradient panel follows the Fill/Stroke proxy).
|
||||
let target = if app.ui.stroke_front { "stroke" } else { "fill" };
|
||||
let fill = if app.ui.stroke_front { it.stroke.as_fill() } else { it.fill.clone() };
|
||||
let Some(g) = designcraft_color::swatch::resolve_gradient(&st.doc.swatches, &fill.swatch).cloned() else {
|
||||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "The fill isn't a gradient.")).color(t.text_dim));
|
||||
crate::rtl::label(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(
|
||||
&app.ui.language,
|
||||
if app.ui.stroke_front { "The stroke isn't a gradient." } else { "The fill isn't a gradient." },
|
||||
))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply Gradient"))).clicked() {
|
||||
let _ = app.run("object.gradient", json!({}));
|
||||
let _ = app.run("object.gradient", json!({"target": target, "target": target}));
|
||||
}
|
||||
return;
|
||||
};
|
||||
@@ -537,7 +547,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
.show_ui(ui, |ui| {
|
||||
for (k, v) in [("Linear", "linear"), ("Radial", "radial")] {
|
||||
if ui.selectable_label(g.kind.label() == k, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, k))).clicked() {
|
||||
let _ = app.run("object.gradient", json!({"kind": v}));
|
||||
let _ = app.run("object.gradient", json!({"target": target, "kind": v}));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -548,7 +558,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
})
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("object.gradient", json!({"reverse": true}));
|
||||
let _ = app.run("object.gradient", json!({"target": target, "reverse": true}));
|
||||
}
|
||||
});
|
||||
let sel_id = egui::Id::new("grad_sel");
|
||||
@@ -560,13 +570,13 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
if let Some(v) = crate::widgets::number(ui, "grad_loc", loc, " %", 54.0, 1) {
|
||||
let mut stops = stops_json(&g);
|
||||
stops[sel]["location"] = json!(v.clamp(0.0, 100.0));
|
||||
let _ = app.run("object.gradient", json!({"stops": stops}));
|
||||
let _ = app.run("object.gradient", json!({"target": target, "stops": stops}));
|
||||
}
|
||||
if !radial {
|
||||
crate::widgets::caption(ui, crate::i18n::tr(&app.ui.language, "Angle:"));
|
||||
let angle = fill.gradient_vector.map(|[x0, y0, x1, y1]| (-(y1 - y0)).atan2(x1 - x0).to_degrees()).or(fill.gradient_angle).unwrap_or(0.0);
|
||||
if let Some(v) = crate::widgets::number(ui, "grad_angle", Some(angle), "°", 54.0, 1) {
|
||||
let _ = app.run("object.gradient", json!({"angle": v}));
|
||||
let _ = app.run("object.gradient", json!({"target": target, "angle": v}));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -616,7 +626,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
} else {
|
||||
stops[i]["location"] = json!(o as f64 * 100.0);
|
||||
}
|
||||
let _ = app.run("object.gradient", json!({"stops": stops}));
|
||||
let _ = app.run("object.gradient", json!({"target": target, "stops": stops}));
|
||||
}
|
||||
if resp.clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
@@ -629,7 +639,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let (c, a) = g.sample(o);
|
||||
let mut stops = stops_json(&g);
|
||||
stops.push(json!({"location": o as f64 * 100.0, "color": c.to_hex(), "opacity": a as f64 * 100.0, "midpoint": 50.0}));
|
||||
let _ = app.run("object.gradient", json!({"stops": stops}));
|
||||
let _ = app.run("object.gradient", json!({"target": target, "stops": stops}));
|
||||
sel = g.stops.iter().filter(|s| s.offset <= o).count();
|
||||
}
|
||||
None => {}
|
||||
@@ -678,7 +688,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
if egui::color_picker::color_edit_button_srgb(ui, &mut rgb).changed() {
|
||||
let mut stops = stops_json(&g);
|
||||
stops[sel]["color"] = json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]));
|
||||
let _ = app.run("object.gradient", json!({"stops": stops}));
|
||||
let _ = app.run("object.gradient", json!({"target": target, "stops": stops}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -428,7 +428,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
||||
- View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough).
|
||||
- OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes.
|
||||
- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`.
|
||||
- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML. A radial gradient stretches with the object's box as InDesign's does (an ellipse reaching the sides of a non-square frame, on screen and in PDF).
|
||||
- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient` (`target: stroke` for a stroke's gradient, drawn across the path's box on screen and in PDF); the vector round-trips through IDML (`GradientFill…`/`GradientStroke…`). The Gradient panel follows the Fill/Stroke proxy. A radial gradient stretches with the object's box as InDesign's does (an ellipse reaching the sides of a non-square frame, on screen and in PDF). Text takes a gradient swatch as its fill or stroke, laid across its frame's box as in InDesign (screen and PDF).
|
||||
- Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup.
|
||||
- Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots.
|
||||
- Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands.
|
||||
|
||||
Reference in New Issue
Block a user