M7: per-paragraph paragraph attributes

Each paragraph has its own alignment, indents, spacing, hyphenation, tab
stops and paragraph style (TextObject.paras; para stays paragraph 0's, so
old files and readers work). Layout, editing (split/merge), threads, the
Paragraph/Tabs/Paragraph Styles panels, ranged text.setStyle/setFormat/
tabs/paraStyle.apply, SVG export/import and the fuzzers follow.
This commit is contained in:
haven
2026-10-08 15:07:13 -07:00
parent 6def232852
commit f7761bf5bc
30 changed files with 991 additions and 169 deletions
+3 -2
View File
@@ -803,9 +803,10 @@ impl Picked {
run_attrs(&mut r.style, &placed, ap);
}
if let Some((_, para)) = text.filter(|_| a.paragraph) {
t.para = para.clone();
// The source's first paragraph's attributes, on every paragraph.
t.set_all_paras(para.clone());
}
if text.is_some() && a.character {
if text.is_some() && (a.character || a.paragraph) {
super::typecmd::refresh_bounds(t);
}
return;
+5 -3
View File
@@ -122,7 +122,7 @@ fn node_refs(n: &Node, f: Visit) {
f(Ref::Name(Res::CharStyle, s));
}
}
if let Some(s) = &t.para.style_name {
for s in t.para_styles().filter_map(|pa| pa.style_name.as_ref()) {
f(Ref::Name(Res::ParaStyle, s));
}
}
@@ -440,8 +440,10 @@ impl Relink {
self.name(Res::CharStyle, s);
}
}
if let Some(s) = &mut t.para.style_name {
self.name(Res::ParaStyle, s);
for pa in t.para_styles_mut() {
if let Some(s) = &mut pa.style_name {
self.name(Res::ParaStyle, s);
}
}
}
NodeKind::Image(im) => self.name(Res::Image, &mut im.key),
+43 -38
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character / Paragraph",
[],
None,
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default)}",
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
has_doc,
set_format
),
@@ -180,6 +180,9 @@ pub(crate) fn push_artboard_copy(d: &mut vectorcraft_doc::Document, src: &vector
// ---------- text ----------
/// Largest indent or paragraph spacing (points; Illustrator's limit).
const MAX_PARA: f64 = 1296.0;
fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setFormat";
let ids: Vec<NodeId> = {
@@ -268,11 +271,11 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(C, "`burasagari` must be \"none\", \"standard\" or \"forced\"")),
}),
};
let range = super::typecmd::TextRange::parse(p, C)?;
s.edit("Character", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
for r in &mut t.runs {
let st = &mut r.style;
super::typecmd::style_chars(t, range, |st| {
if let Some(k) = kerning {
st.kerning = k;
}
@@ -306,41 +309,43 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = char_align {
st.char_align = v;
}
}
let para = &mut t.para;
if let Some(v) = num("leftIndent") {
para.left_indent = v;
}
if let Some(v) = num("rightIndent") {
para.right_indent = v;
}
if let Some(v) = num("firstLineIndent") {
para.first_line_indent = v;
}
if let Some(v) = num("spaceBefore") {
para.space_before = v;
}
if let Some(v) = num("spaceAfter") {
para.space_after = v;
}
if let Some(v) = flag("hyphenate") {
para.hyphenate = v;
}
if let Some(v) = mojikumi {
para.mojikumi = v;
}
if let Some(v) = direction {
para.direction = v;
}
if let Some(v) = leading_model {
para.leading_model = v;
}
if let Some(v) = burasagari {
para.burasagari = v;
}
if let Some(v) = composer {
para.composer = v;
}
});
let span = super::typecmd::para_span(range, t);
t.edit_paras(span, |para| {
if let Some(v) = num("leftIndent") {
para.left_indent = v.clamp(-MAX_PARA, MAX_PARA);
}
if let Some(v) = num("rightIndent") {
para.right_indent = v.clamp(-MAX_PARA, MAX_PARA);
}
if let Some(v) = num("firstLineIndent") {
para.first_line_indent = v.clamp(-MAX_PARA, MAX_PARA);
}
if let Some(v) = num("spaceBefore") {
para.space_before = v.clamp(-MAX_PARA, MAX_PARA);
}
if let Some(v) = num("spaceAfter") {
para.space_after = v.clamp(-MAX_PARA, MAX_PARA);
}
if let Some(v) = flag("hyphenate") {
para.hyphenate = v;
}
if let Some(v) = mojikumi {
para.mojikumi = v;
}
if let Some(v) = direction {
para.direction = v;
}
if let Some(v) = leading_model {
para.leading_model = v;
}
if let Some(v) = burasagari {
para.burasagari = v;
}
if let Some(v) = composer {
para.composer = v;
}
});
super::typecmd::refresh_bounds(t);
}
Ok(())
+22 -6
View File
@@ -3,7 +3,7 @@
use serde_json::{Value, json};
use vectorcraft_doc::{NodeKind, TabAlign, TabStop};
use super::typecmd::{refresh_bounds, text_targets};
use super::typecmd::{TextRange, para_span, refresh_bounds, text_targets};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
@@ -13,12 +13,25 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Tab Stops",
["Window", "Tabs"],
None,
"{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?} replace the tab stops of the selected text",
"{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches)",
has_selection,
set
),
cmd!(query "text.tabs.get", "Tab Stops", [], None, "{ids?} → {stops} of the first selected text object", has_selection, get),
cmd!("text.tabs.clear", "Clear All Tabs", ["Window", "Tabs"], None, "{ids?}", has_selection, |s, p| apply(s, p, vec![], "Clear All Tabs")),
cmd!(
query "text.tabs.get",
"Tab Stops",
[],
None,
"{ids?, start?: byte} → {stops} of the first selected text object (its paragraph at `start`; default the first)",
has_selection,
get
),
cmd!("text.tabs.clear", "Clear All Tabs", ["Window", "Tabs"], None, "{ids?, start?: byte, end?: byte}", has_selection, |s, p| apply(
s,
p,
vec![],
"Clear All Tabs"
)),
]
}
@@ -54,11 +67,13 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
fn apply(s: &mut Session, p: &Value, stops: Vec<TabStop>, label: &str) -> Result<Value> {
let ids = text_targets(s, p, "text.tabs.set")?;
let range = TextRange::parse(p, "text.tabs.set")?;
let n = stops.len();
s.edit(label, |d, _| {
for id in &ids {
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
t.para.tabs = stops.clone();
let span = para_span(range, t);
t.edit_paras(span, |pa| pa.tabs = stops.clone());
refresh_bounds(t);
}
}
@@ -69,9 +84,10 @@ fn apply(s: &mut Session, p: &Value, stops: Vec<TabStop>, label: &str) -> Result
fn get(s: &mut Session, p: &Value) -> Result<Value> {
let ids = text_targets(s, p, "text.tabs.get")?;
let range = TextRange::parse(p, "text.tabs.get")?;
let st = s.doc()?;
let tabs = ids.iter().find_map(|id| match st.doc.node(*id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some(t.para.tabs.clone()),
Some(NodeKind::Text(t)) => Some(t.para_at(para_span(range, t).map_or(0, |r| r.start)).tabs.clone()),
_ => None,
});
Ok(json!({ "stops": serde_json::to_value(tabs.unwrap_or_default()).unwrap_or(Value::Null) }))
+9
View File
@@ -135,6 +135,13 @@ fn edit_range(s: &mut Session, p: &Value) -> Result<Value> {
let insert = str_param(p, "insert").unwrap_or("").to_string();
let caret = s.edit("Typing", |d, _| {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
// Paragraph styles follow the edit: a split paragraph's new one continues its style, a
// merge keeps the first paragraph's.
let inserted: String = match &styled {
Some(r) => r.iter().map(|r| r.text.as_str()).collect(),
None => insert.clone(),
};
t.splice_paras(a, b, &inserted);
let caret = match &styled {
Some(r) => edit::replace_range_styled(&mut t.runs, a, b, r),
None => edit::replace_range(&mut t.runs, a, b, &insert),
@@ -463,6 +470,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
xf: Affine::IDENTITY,
runs: vec![TextRun { text, style }],
para: super::create::new_type_para(),
paras: Vec::new(),
area: Default::default(),
path_effect: Default::default(),
path_align: Default::default(),
@@ -600,6 +608,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
xf: Affine::IDENTITY,
runs: head.clone(),
para: Default::default(),
paras: Vec::new(),
area: Default::default(),
path_effect: Default::default(),
path_align: Default::default(),
+28 -16
View File
@@ -66,7 +66,7 @@ macro_rules! style_cmds {
concat!($p, ".apply"),
concat!("Apply ", $what, " Style"),
&[],
"{name, clearOverrides?, ids?|id + start?/end? (a Type tool range)} → {count}",
"{name, clearOverrides?, ids?|id + start?/end? (a Type tool range: character styles style it, paragraph styles apply to the paragraphs it touches)} → {count}",
|s, p| apply(s, p, $kind),
true,
),
@@ -179,7 +179,7 @@ fn text_mut(d: &mut Document, id: NodeId) -> Option<&mut vectorcraft_doc::TextOb
}
}
/// How many runs (char) or text objects (para) use `name`.
/// How many runs (char) or paragraphs (para) use `name`.
fn uses(d: &Document, kind: Kind, name: &str) -> usize {
let target = (name != kind.normal()).then_some(name);
let mut n = 0;
@@ -187,7 +187,7 @@ fn uses(d: &Document, kind: Kind, name: &str) -> usize {
if let NodeKind::Text(t) = &node.kind {
n += match kind {
Kind::Char => t.runs.iter().filter(|r| r.style.style_name.as_deref() == target).count(),
Kind::Para => usize::from(t.para.style_name.as_deref() == target),
Kind::Para => (0..t.paragraph_count()).filter(|&i| t.para_at(i).style_name.as_deref() == target).count(),
};
}
});
@@ -210,9 +210,11 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map<String, Valu
}
}
Kind::Para => {
if t.para.style_name == target {
t.para = restyle(&t.para, old, new);
changed = true;
for pa in t.para_styles_mut() {
if pa.style_name == target {
*pa = restyle(pa, old, new);
changed = true;
}
}
}
}
@@ -231,12 +233,11 @@ fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result<Option<Map<Stri
None => text_targets(s, &json!({}), "").ok().and_then(|v| v.first().copied()),
};
let Some(NodeKind::Text(t)) = id.and_then(|i| d.node(i)).map(|n| &n.kind) else { return Ok(None) };
let at = p.get("start").and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX));
Ok(Some(match kind {
Kind::Char => {
let at = p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize;
to_attrs(&vectorcraft_text::edit::style_at(&t.runs, at))
}
Kind::Para => to_attrs(&t.para),
Kind::Char => to_attrs(&vectorcraft_text::edit::style_at(&t.runs, at)),
// The paragraph at `start`.
Kind::Para => to_attrs(t.para_at(t.paragraphs_in(at, at).start)),
}))
}
@@ -329,8 +330,9 @@ fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
let normal = kind.attrs(d, kind.normal()).unwrap_or_default();
let clear = bool_or(p, "clearOverrides", false);
let style_name = (name != kind.normal()).then(|| name.clone());
// A Type tool range (character styles only) or whole text objects.
let range = id_param(p, "id").filter(|_| kind == Kind::Char && (p.get("start").is_some() || p.get("end").is_some()));
// A Type tool range or whole text objects.
let text_range = super::typecmd::TextRange::parse(p, &c)?;
let range = id_param(p, "id").filter(|_| text_range.is_some());
let ids = match range.or_else(|| id_param(p, "id")) {
Some(id) => vec![id],
None => text_targets(s, p, &c)?,
@@ -376,7 +378,17 @@ fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
return Err(e);
}
}
Kind::Para => t.para = para_fn(&t.para)?,
Kind::Para => {
let mut v = t.paragraph_styles();
let span = match (range, text_range) {
(Some(_), Some(r)) => r.paras(t),
_ => 0..v.len(),
};
for st in v.iter_mut().take(span.end).skip(span.start) {
*st = para_fn(st)?;
}
t.set_paragraph_styles(v);
}
}
refresh_bounds(t);
}
@@ -447,7 +459,7 @@ fn rename(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
};
match kind {
Kind::Char => t.runs.iter_mut().for_each(|r| fix(&mut r.style.style_name)),
Kind::Para => fix(&mut t.para.style_name),
Kind::Para => t.para_styles_mut().for_each(|pa| fix(&mut pa.style_name)),
}
}
Ok(())
@@ -472,7 +484,7 @@ fn delete(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
};
match kind {
Kind::Char => t.runs.iter_mut().for_each(|r| fix(&mut r.style.style_name)),
Kind::Para => fix(&mut t.para.style_name),
Kind::Para => t.para_styles_mut().for_each(|pa| fix(&mut pa.style_name)),
}
}
Ok(())
+33 -13
View File
@@ -75,14 +75,24 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
});
// The story: live frames' runs, plus the text of frames deleted by this edit.
let mut story: Vec<TextRun> = vec![];
let mut sources: Vec<TextObject> = vec![];
let mut later_wins = vec![];
let mut live = vec![];
for id in &thread {
match (text_of(doc, *id), text_of(before, *id)) {
(Some(t), _) => {
(Some(t), old) => {
story.extend(t.runs.iter().cloned());
// A frame whose first paragraph was just restyled gives the paragraph it
// shares with the frame before it that style.
later_wins.push(old.is_some_and(|o| o.para_at(0) != t.para_at(0)));
sources.push(t.clone());
live.push(*id);
}
(None, Some(old)) => story.extend(old.runs.iter().cloned()),
(None, Some(old)) => {
story.extend(old.runs.iter().cloned());
later_wins.push(false);
sources.push(old.clone());
}
(None, None) => {}
}
}
@@ -92,6 +102,8 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
}
continue;
}
let source_refs: Vec<&TextObject> = sources.iter().collect();
let paras = vectorcraft_text::thread::story_paras(&source_refs, &later_wins);
vectorcraft_text::edit::normalize(&mut story);
if live.len() >= 2 {
// Threaded frames keep their size: Auto Size is off for them (as in Illustrator).
@@ -105,13 +117,18 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
}
let frames: Vec<TextObject> = live.iter().filter_map(|id| text_of(doc, *id).cloned()).collect();
let refs: Vec<&TextObject> = frames.iter().collect();
let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &refs, &story);
for (id, runs) in live.iter().zip(parts) {
if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind)
&& t.runs != runs
{
t.runs = runs;
refresh_bounds(t);
let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &refs, &story, &paras);
for (id, part) in live.iter().zip(parts) {
if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) {
let mut next = (**t).clone();
next.runs = part.runs;
next.set_paragraph_styles(part.paras);
if next.runs != t.runs || next.para != t.para || next.paras != t.paras {
t.runs = next.runs;
t.para = next.para;
t.paras = next.paras;
refresh_bounds(t);
}
}
}
if live.len() >= 2 {
@@ -193,15 +210,17 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
continue;
}
let mut story: Vec<TextRun> = vec![];
for id in &thread {
if let Some(t) = text_of(d, *id) {
story.extend(t.runs.iter().cloned());
}
let sources: Vec<TextObject> = thread.iter().filter_map(|id| text_of(d, *id).cloned()).collect();
for t in &sources {
story.extend(t.runs.iter().cloned());
}
let paras = vectorcraft_text::thread::story_paras(&sources.iter().collect::<Vec<_>>(), &[]);
let style = story.first().map(|r| r.style.clone()).unwrap_or_default();
let first_para = paras.first().cloned().unwrap_or_default();
for id in &out {
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
t.runs = vec![TextRun { text: String::new(), style: style.clone() }];
t.set_all_paras(first_para.clone());
refresh_bounds(t);
}
}
@@ -210,6 +229,7 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
{
vectorcraft_text::edit::normalize(&mut story);
t.runs = story;
t.set_paragraph_styles(paras);
}
let i = d.text_threads.iter().position(|t| *t == thread).unwrap_or(0);
d.text_threads[i] = keep;
+66 -6
View File
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType}",
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)",
has_doc,
set_style
),
@@ -102,6 +102,8 @@ fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result<Value> {
/// ([`vectorcraft_doc::AreaFit::AutoHeight`]) fits its frame to the text: part of the same undo
/// step as the edit.
pub(crate) fn refresh_bounds(t: &mut TextObject) {
// Edits that changed the paragraph count keep one paragraph style per paragraph.
t.normalize_paras();
auto_height(t);
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
t.cached_bounds = Some(lay.bounds);
@@ -255,6 +257,60 @@ pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Node
Ok(t)
}
/// Optional `start`/`end` byte offsets of a text command: the character range it styles and the
/// paragraphs it touches. Without either, the command applies to all the text.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct TextRange {
start: Option<usize>,
end: Option<usize>,
}
impl TextRange {
/// `start`/`end` of `p`, if either is given (non-negative integers).
pub(crate) fn parse(p: &Value, cmd: &str) -> Result<Option<Self>> {
let get = |k: &str| -> Result<Option<usize>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(v) => {
v.as_u64().map(|n| Some(usize::try_from(n).unwrap_or(usize::MAX))).ok_or_else(|| bad(cmd, format!("`{k}` must be a byte offset")))
}
}
};
let (start, end) = (get("start")?, get("end")?);
Ok((start.is_some() || end.is_some()).then_some(Self { start, end }))
}
/// The byte range in `t`, clamped and ordered.
pub(crate) fn bytes(&self, t: &TextObject) -> (usize, usize) {
let len = vectorcraft_text::edit::runs_len(&t.runs);
let a = self.start.unwrap_or(0).min(len);
let b = self.end.unwrap_or(len).min(len);
(a.min(b), a.max(b))
}
/// The paragraphs of `t` the range touches.
pub(crate) fn paras(&self, t: &TextObject) -> std::ops::Range<usize> {
let (a, b) = self.bytes(t);
t.paragraphs_in(a, b)
}
}
/// Paragraphs of `t` an optional range touches (None: every paragraph).
pub(crate) fn para_span(range: Option<TextRange>, t: &TextObject) -> Option<std::ops::Range<usize>> {
range.map(|r| r.paras(t))
}
/// Apply `f` to the character styles of `range` of `t` (None: every run).
pub(crate) fn style_chars(t: &mut TextObject, range: Option<TextRange>, mut f: impl FnMut(&mut vectorcraft_doc::CharStyle)) {
match range {
None => t.runs.iter_mut().for_each(|r| f(&mut r.style)),
Some(r) => {
let (a, b) = r.bytes(t);
if a < b {
vectorcraft_text::edit::style_range(&mut t.runs, a, b, f);
}
}
}
}
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_param(p, "text").ok_or_else(|| bad("text.setText", "missing `text`"))?.to_string();
let ids = text_targets(s, p, "text.setText")?;
@@ -262,6 +318,8 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let style = t.first_style();
// Every paragraph of the new text takes the first paragraph's attributes.
t.splice_paras(0, vectorcraft_text::edit::runs_len(&t.runs), &text);
t.runs = vec![vectorcraft_doc::TextRun { text: text.clone(), style }];
refresh_bounds(t);
}
@@ -319,14 +377,14 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
{
return Err(bad(C, "nothing to change"));
}
let range = TextRange::parse(p, C)?;
let ids = text_targets(s, p, C)?;
let protect = s.prefs.missing_glyph_protection && (font.is_some() || style.is_some());
s.edit("Character", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let before = protect.then(|| t.runs.clone());
for r in &mut t.runs {
let st = &mut r.style;
style_chars(t, range, |st| {
if let Some(f) = &font {
st.font_family = f.clone();
}
@@ -348,12 +406,13 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(f) = &features {
st.features = f.clone();
}
}
});
if let Some(before) = before {
super::textedit::protect_missing_glyphs(&before, &mut t.runs);
}
if let Some(j) = justify {
t.para.justify = j;
let span = para_span(range, t);
t.edit_paras(span, |pa| pa.justify = j);
}
refresh_bounds(t);
}
@@ -788,7 +847,8 @@ fn headline_fit(t: &TextObject, tracking: f64) -> Option<(f64, f64, usize)> {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe);
let cell = lay.frames.first()?;
let line = lay.lines.first()?;
let avail = cell.width() - 2.0 * t.area.inset - t.para.left_indent - t.para.right_indent;
let para = t.para_at(0);
let avail = cell.width() - 2.0 * t.area.inset - para.left_indent - para.right_indent;
Some((line.x1 - line.x0, avail, lay.lines.iter().filter(|l| l.start < para_end).count()))
}
+9 -7
View File
@@ -272,7 +272,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
let top = lay.lines.first().map(|l| l.baseline - l.ascent).unwrap_or(b.y0).min(b.y0);
let slack = size * 0.5;
// Slack goes where the alignment leaves room, so the text doesn't move or rewrap.
let (sl, sr) = match t.para.justify {
let (sl, sr) = match t.para_at(0).justify {
Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5),
Justify::Right | Justify::JustifyRight => (slack, 0.0),
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0),
@@ -315,20 +315,22 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
breaks.sort_unstable();
breaks.dedup();
for b in breaks.into_iter().rev() {
// Each wrapped line becomes a paragraph with its paragraph's attributes.
if plain.as_bytes().get(b - 1) == Some(&b' ') {
if let Some((ri, bi)) = locate(&t.runs, b - 1) {
t.splice_paras(b - 1, b, "\n");
t.runs[ri].text.replace_range(bi..bi + 1, "\n");
}
} else if let Some((ri, bi)) = locate(&t.runs, b) {
t.splice_paras(b, b, "\n");
t.runs[ri].text.insert(bi, '\n');
}
}
// The point origin sits where the alignment anchors the first line.
let (x0, x1) = lay
.lines
.first()
.map_or((fb.x0, fb.x1), |l| (l.avail.0 - t.para.left_indent - t.para.first_line_indent, l.avail.1 + t.para.right_indent));
let ox = match t.para.justify {
let p0 = t.para_at(0);
let (x0, x1) =
lay.lines.first().map_or((fb.x0, fb.x1), |l| (l.avail.0 - p0.left_indent - p0.first_line_indent, l.avail.1 + p0.right_indent));
let ox = match p0.justify {
Justify::Center | Justify::JustifyCenter => (x0 + x1) * 0.5,
Justify::Right | Justify::JustifyRight => x1,
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => x1,
@@ -646,7 +648,7 @@ fn strip_formatting(n: &mut vectorcraft_doc::Node) {
if let NodeKind::Text(t) = &mut n.kind {
let text = t.plain_text();
t.runs = vec![TextRun { text, style: CharStyle::default() }];
t.para = Default::default();
t.set_all_paras(Default::default());
refresh_bounds(t);
}
if let Some(ch) = n.children_mut() {
+2
View File
@@ -1431,6 +1431,8 @@ mod tests_paintproxy;
#[cfg(test)]
mod tests_panelcmds;
#[cfg(test)]
mod tests_paragraphs;
#[cfg(test)]
mod tests_pathops;
#[cfg(test)]
mod tests_pathtype;
+207
View File
@@ -0,0 +1,207 @@
//! Paragraph attributes per paragraph: ranged Paragraph panel commands, paragraph styles on one
//! paragraph, splitting and merging paragraphs while editing, undo and threaded text.
use serde_json::json;
use vectorcraft_doc::{Justify, NodeId, NodeKind, TextObject};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn area(s: &mut Session, text: &str) -> u64 {
s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 300, "height": 300}})).unwrap()["id"].as_u64().unwrap()
}
fn text(s: &Session, id: u64) -> TextObject {
match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("not text"),
}
}
fn justs(s: &Session, id: u64) -> Vec<Justify> {
text(s, id).paragraph_styles().iter().map(|p| p.justify).collect()
}
#[test]
fn ranged_paragraph_commands_touch_only_their_paragraphs() {
let mut s = session();
let id = area(&mut s, "one\ntwo\nthree");
// A caret in "two" (byte 5): only the second paragraph.
s.execute("text.setStyle", &json!({"id": id, "justify": "center", "start": 5, "end": 5})).unwrap();
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Auto]);
// A selection from "one" into "two": both.
s.execute("text.setFormat", &json!({"ids": [id], "spaceBefore": 9, "leftIndent": 4, "start": 1, "end": 5})).unwrap();
let t = text(&s, id);
let sb: Vec<f64> = t.paragraph_styles().iter().map(|p| p.space_before).collect();
assert_eq!(sb, [9.0, 9.0, 0.0]);
assert_eq!(t.para.left_indent, 4.0, "paragraph 0 is `para`");
// Character attributes of setFormat follow the range too.
s.execute("text.setFormat", &json!({"ids": [id], "underline": true, "start": 4, "end": 7})).unwrap();
let t = text(&s, id);
assert!(t.runs.iter().any(|r| r.text == "two" && r.style.underline));
assert!(t.runs.iter().any(|r| r.text.starts_with("one") && !r.style.underline));
// The layout sees each paragraph's alignment.
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
assert!(lay.lines[1].x0 > lay.lines[0].x0 + 20.0, "the centred line sits right of the left one");
// No range: every paragraph (as before).
s.execute("text.setStyle", &json!({"id": id, "justify": "right"})).unwrap();
assert_eq!(justs(&s, id), [Justify::Right; 3]);
// Undo steps back through the ranged edits.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Auto]);
// Tabs per paragraph; a query reads the paragraph at `start`.
s.execute("text.tabs.set", &json!({"ids": [id], "stops": [{"position": 40}], "start": 9, "end": 9})).unwrap();
assert_eq!(s.execute("text.tabs.get", &json!({"ids": [id], "start": 9})).unwrap()["stops"].as_array().unwrap().len(), 1);
assert_eq!(s.execute("text.tabs.get", &json!({"ids": [id]})).unwrap()["stops"], json!([]));
// Junk ranges are errors, not panics.
assert!(s.execute("text.setFormat", &json!({"ids": [id], "spaceAfter": 1, "start": -3})).is_err());
assert!(s.execute("text.setStyle", &json!({"id": id, "justify": "left", "end": "x"})).is_err());
s.execute("text.setFormat", &json!({"ids": [id], "spaceAfter": 1, "start": 1_000_000, "end": u64::MAX})).unwrap();
assert_eq!(text(&s, id).para_at(2).space_after, 1.0, "past the end: the last paragraph");
}
#[test]
fn editing_splits_and_merges_paragraph_styles() {
let mut s = session();
let id = area(&mut s, "one\ntwo\nthree");
s.execute("text.setStyle", &json!({"id": id, "justify": "center", "start": 5, "end": 5})).unwrap();
// A break inside "two": both halves keep its style.
s.execute("text.editRange", &json!({"id": id, "start": 5, "end": 5, "insert": "\n"})).unwrap();
assert_eq!(text(&s, id).plain_text(), "one\nt\nwo\nthree");
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Center, Justify::Auto]);
// Deleting the break after "one": the first paragraph's style wins.
s.execute("text.editRange", &json!({"id": id, "start": 3, "end": 4})).unwrap();
assert_eq!(text(&s, id).plain_text(), "onet\nwo\nthree");
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Auto]);
// Pasting styled runs with breaks in "wo": the new paragraphs continue it.
let runs = json!([{"text": "A\nB\n", "style": text(&s, id).runs[0].style}]);
s.execute("text.editRange", &json!({"id": id, "start": 6, "end": 6, "runs": runs})).unwrap();
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Center, Justify::Center, Justify::Auto]);
// Undo restores the styles with the text.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Center, Justify::Auto]);
// Replacing all the text: every paragraph takes the first one's attributes.
s.execute("text.setText", &json!({"ids": [id], "text": "x\ny"})).unwrap();
assert_eq!(justs(&s, id), [Justify::Auto, Justify::Auto]);
assert!(text(&s, id).paras.is_empty());
}
#[test]
fn return_in_the_type_tool_continues_the_paragraph_it_splits() {
let mut s = session();
let v = ViewInfo::default();
let id = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "one\ntwo", "size": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("text.setStyle", &json!({"id": id, "justify": "right", "start": 0, "end": 0})).unwrap();
s.execute("text.setStyle", &json!({"id": id, "justify": "center", "start": 5, "end": 5})).unwrap();
s.select_tool("type", v).unwrap();
// Click into the first line (right-aligned: it ends at the origin), then End and Return.
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id));
let l0 = &lay.lines[0];
let (x, y) = (100.0 + (l0.x0 + l0.x1) / 2.0, 100.0 + l0.baseline - 5.0);
for k in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
}
s.tool_key(ToolKey::End, Mods::default(), v).unwrap();
s.tool_key(ToolKey::Enter, Mods::default(), v).unwrap();
s.tool_text("new", v).unwrap();
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
assert_eq!(text(&s, id).plain_text(), "one\nnew\ntwo");
assert_eq!(justs(&s, id), [Justify::Right, Justify::Right, Justify::Center]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(justs(&s, id), [Justify::Right, Justify::Center]);
}
#[test]
fn paragraph_style_applies_to_one_paragraph() {
let mut s = session();
let id = area(&mut s, "Title\nBody text");
// The second paragraph keeps new type's attributes, whatever they are.
let body = text(&s, id).para_at(1).clone();
s.execute("paraStyle.new", &json!({"name": "Head", "attrs": {"justify": "Center", "space_after": 8.0}})).unwrap();
s.execute("paraStyle.apply", &json!({"name": "Head", "id": id, "start": 2, "end": 2})).unwrap();
let t = text(&s, id);
assert_eq!(t.para_at(0).style_name.as_deref(), Some("Head"));
assert_eq!((t.para_at(0).justify, t.para_at(0).space_after), (Justify::Center, 8.0));
assert_eq!(t.para_at(1), &body, "new type's");
let list = s.execute("paraStyle.list", &json!({})).unwrap();
assert_eq!(list["styles"][1]["uses"], 1, "one paragraph uses it");
assert_eq!(list["styles"][0]["uses"], 1, "the other is Normal");
// Redefining updates that paragraph only; renaming follows it.
s.execute("paraStyle.setAttrs", &json!({"name": "Head", "attrs": {"justify": "Right"}})).unwrap();
assert_eq!(justs(&s, id), [Justify::Right, Justify::Auto]);
s.execute("paraStyle.rename", &json!({"name": "Head", "to": "Heading"})).unwrap();
assert_eq!(text(&s, id).para_at(0).style_name.as_deref(), Some("Heading"));
// New from the selection reads the caret's paragraph.
s.execute("paraStyle.new", &json!({"name": "From body", "id": id, "start": 8})).unwrap();
let defs = &s.doc().unwrap().doc.para_styles;
assert_eq!(defs.iter().find(|d| d.name == "From body").unwrap().attrs["justify"], "Auto");
}
#[test]
fn threaded_frames_keep_each_paragraphs_style() {
let mut s = session();
let story = "The quick brown fox jumps over the lazy dog and keeps running. ".repeat(3) + "\nSecond paragraph.\nThird.";
let a =
s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
let joined = |s: &Session| format!("{}{}", text(s, a).plain_text(), text(s, b).plain_text());
assert_eq!(joined(&s), story);
// Centre the second paragraph (in frame b) and right-align the third.
let tb = text(&s, b).plain_text();
let second = tb.find("Second").unwrap();
s.execute("text.setStyle", &json!({"id": b, "justify": "center", "start": second, "end": second})).unwrap();
let third = text(&s, b).plain_text().find("Third").unwrap();
s.execute("text.setStyle", &json!({"id": b, "justify": "right", "start": third, "end": third})).unwrap();
assert_eq!(justs(&s, b), [Justify::Auto, Justify::Center, Justify::Right]);
// Typing in the first frame re-flows the story: the styles move with their paragraphs.
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "More words at the start of the story. "})).unwrap();
let (ja, jb) = (justs(&s, a), justs(&s, b));
assert_eq!(ja, [Justify::Auto]);
assert_eq!(jb, [Justify::Auto, Justify::Center, Justify::Right]);
// A break in the first frame makes a new paragraph there; the later ones keep their styles.
s.execute("text.editRange", &json!({"id": a, "start": 4, "end": 4, "insert": "\n"})).unwrap();
let all: Vec<Justify> = [justs(&s, a), justs(&s, b)].concat();
assert_eq!(all.iter().filter(|j| **j == Justify::Center).count(), 1);
assert_eq!(all.last(), Some(&Justify::Right));
// Restyling the paragraph that continues into frame b (its first) sticks after the re-flow.
s.execute("text.setStyle", &json!({"id": b, "justify": "justifyAll", "start": 0, "end": 0})).unwrap();
assert_eq!(justs(&s, b)[0], Justify::JustifyAll);
assert_eq!(*justs(&s, a).last().unwrap(), Justify::JustifyAll, "the paragraph's part in frame a follows");
}
/// Upstream's paragraph attributes (direction, mojikumi, leading model) follow a range like the
/// others: only the touched paragraphs change, and the layout reads each paragraph's own.
#[test]
fn ranged_set_format_sets_direction_mojikumi_and_leading_model_per_paragraph() {
use vectorcraft_doc::{LeadingModel, Mojikumi, ParaDirection};
let mut s = session();
let id = area(&mut s, "Hello.\nHello.");
// A caret in the second paragraph (byte 9).
s.execute(
"text.setFormat",
&json!({"ids": [id], "direction": "rightToLeft", "leadingModel": "emBoxTop", "mojikumi": "none", "start": 9, "end": 9}),
)
.unwrap();
let t = text(&s, id);
let (p0, p1) = (t.para_at(0).clone(), t.para_at(1).clone());
assert_eq!((p0.direction, p0.leading_model, p0.mojikumi), (None, LeadingModel::RomanBaseline, Mojikumi::LineEndHalf));
assert_eq!((p1.direction, p1.leading_model, p1.mojikumi), (Some(ParaDirection::RightToLeft), LeadingModel::EmBoxTop, Mojikumi::None));
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
assert!(!lay.lines[0].rtl && lay.lines[1].rtl, "each paragraph's direction");
// Return in the right-to-left paragraph continues it.
s.execute("text.editRange", &json!({"id": id, "start": 13, "end": 13, "insert": "\nX"})).unwrap();
let t = text(&s, id);
assert_eq!(t.para_at(2).direction, Some(ParaDirection::RightToLeft));
// No range: every paragraph.
s.execute("text.setFormat", &json!({"ids": [id], "direction": "auto"})).unwrap();
assert!(text(&s, id).paragraph_styles().iter().all(|p| p.direction.is_none()));
}