mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 03:06:56 -06:00
2945 lines
143 KiB
Rust
2945 lines
143 KiB
Rust
//! Text editing (Type tool) and character/paragraph formatting.
|
||
|
||
use designcraft_compose as compose;
|
||
use designcraft_doc::story::floor_char_boundary;
|
||
use designcraft_doc::{CellAddr, CharAttrs, Content, ItemId, ParaAttrs, Selection, StoryId, TextSel};
|
||
use designcraft_geom::Point;
|
||
use serde_json::{Value, json};
|
||
|
||
use super::{CommandSpec, bad, bool_or, cmd, has_doc, has_text, has_text_or_frames, id_param, ok, point_param, str_param};
|
||
use crate::{Result, Session};
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!(noundo "text.placeCaret", "Place Caret", [], None, "{frame, point: [x,y] (spread coords)} — converts empty frames to text frames", has_doc, |s, p| place(s, p, false)),
|
||
cmd!(
|
||
"text.release",
|
||
"Release",
|
||
[],
|
||
None,
|
||
"{frame, point, moved: bool, copy?: bool} — ends a press in text: a drag that started in the selected text moves (copy: duplicates) it to the point; a click places the caret",
|
||
has_doc,
|
||
release
|
||
),
|
||
cmd!(noundo "text.extendTo", "Extend Text Selection", [], None, "{frame, point}", has_doc, |s, p| place(s, p, true)),
|
||
cmd!(noundo "text.selectWord", "Select Word", [], None, "{frame, point} — the word (or run of spaces) under the point", has_doc, |s, p| {
|
||
select_at(s, p, "word")
|
||
}),
|
||
cmd!(
|
||
noundo "text.selectAt",
|
||
"Select Text Unit",
|
||
[],
|
||
None,
|
||
"{frame, point, unit: word|line|paragraph|story} — what a double, triple, quadruple or quintuple click selects: the word (or run of spaces) under the point, its line, its paragraph with the paragraph return, or the whole story",
|
||
has_doc,
|
||
|s, p| select_at(s, p, str_param(p, "unit").unwrap_or("word"))
|
||
),
|
||
cmd!(noundo "text.select", "Select Text", [], None, "{story, anchor, focus} (UTF-8 byte offsets into the story text, as find.find reports them: á or — counts 2 or 3)", has_doc, |s, p| {
|
||
let sid = StoryId(p.get("story").and_then(Value::as_u64).unwrap_or(0));
|
||
s.typing_format = None;
|
||
let st = s.doc_mut()?;
|
||
let text = &st.doc.story(sid).ok_or_else(|| bad("text.select", "no such story"))?.text;
|
||
let a = floor_char_boundary(text, p.get("anchor").and_then(Value::as_u64).unwrap_or(0) as usize);
|
||
let f = floor_char_boundary(text, p.get("focus").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a));
|
||
st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: f, frame: None, cell: None });
|
||
st.bump_revision();
|
||
ok()
|
||
}),
|
||
cmd!("text.insert", "Type", [], None, "{text, raw?: bool (no typographer's quotes)} — replaces the selected text", has_text, insert),
|
||
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, _| {
|
||
let st = s.doc_mut()?;
|
||
if let Some(t) = st.selection.text.take() {
|
||
let f = t.frame.or_else(|| st.doc.story(t.story).and_then(|x| x.frames.first().copied()));
|
||
st.selection.items = f.into_iter().collect();
|
||
}
|
||
st.selection.cells = None;
|
||
st.bump_revision();
|
||
ok()
|
||
}),
|
||
cmd!(
|
||
"story.setDirection",
|
||
"Story Column Direction",
|
||
["Type", "Story Column Direction"],
|
||
None,
|
||
"{story?, direction: leftToRight|rightToLeft} — column progression, independent of paragraph direction",
|
||
has_doc,
|
||
|s, p| {
|
||
let sid = story_of(s, p).ok_or_else(|| bad("story.setDirection", "no story"))?;
|
||
let direction: designcraft_doc::TextDirection =
|
||
serde_json::from_value(p.get("direction").cloned().ok_or_else(|| bad("story.setDirection", "missing direction"))?)
|
||
.map_err(|e| bad("story.setDirection", e.to_string()))?;
|
||
s.edit(|d, _| {
|
||
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
|
||
st.direction = direction;
|
||
st.rev = st.rev.wrapping_add(1);
|
||
ok()
|
||
})
|
||
}
|
||
),
|
||
cmd!("story.setText", "Set Story Text", [], None, "{story, text} — replace a story's whole text", has_doc, |s, p| {
|
||
let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.setText", "missing story"))?);
|
||
let text = super::text_param(p, "text");
|
||
s.edit(|d, sel| {
|
||
d.set_story_text(sid, &text)?;
|
||
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) {
|
||
t.anchor = floor_char_boundary(&text, t.anchor);
|
||
t.focus = floor_char_boundary(&text, t.focus);
|
||
}
|
||
ok()
|
||
})
|
||
}),
|
||
cmd!(
|
||
"story.replaceRange",
|
||
"Edit Story",
|
||
[],
|
||
None,
|
||
"{story, start, end, text} — replace a byte range keeping surrounding formatting",
|
||
has_doc,
|
||
|s, p| {
|
||
let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.replaceRange", "missing story"))?);
|
||
let a = p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||
let b = p.get("end").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a);
|
||
let text = super::text_param(p, "text");
|
||
s.edit(|d, sel| {
|
||
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
|
||
let (a, b) = (a.min(st.len()), b.min(st.len()).max(a.min(st.len())));
|
||
st.replace(a..b, &text);
|
||
let len = st.len();
|
||
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) {
|
||
t.anchor = floor_char_boundary(&st.text, t.anchor);
|
||
t.focus = floor_char_boundary(&st.text, t.focus);
|
||
}
|
||
Ok(json!({"length": len}))
|
||
})
|
||
}
|
||
),
|
||
cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, overset", has_doc, |s, p| {
|
||
let st = s.doc()?;
|
||
let sid = story_of(s, p).ok_or_else(|| bad("story.get", "no story"))?;
|
||
let mut v = st.doc.story_summary(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
|
||
let cs = s.cache.get(&st.doc, sid, None);
|
||
v["overset"] = json!(cs.overset_at);
|
||
v["lines"] = json!(cs.line_count());
|
||
Ok(v)
|
||
}),
|
||
cmd!("type.fillWithPlaceholder", "Fill with Placeholder Text", ["Type"], None, "{frame?}", has_text_or_frames, fill_placeholder),
|
||
cmd!(
|
||
"type.char",
|
||
"Character Formatting",
|
||
[],
|
||
None,
|
||
"{attrs: {fontFamily?, fontStyle?, size?, leading?: {kind:auto}|{kind:points,value}, tracking?, kerning?, hScale?, vScale?, baselineShift?, fill?, capitalization?, underline?, …}}",
|
||
has_text_or_frames,
|
||
|s, p| format_chars(s, p.get("attrs").unwrap_or(p))
|
||
),
|
||
cmd!(
|
||
"type.para",
|
||
"Paragraph Formatting",
|
||
[],
|
||
None,
|
||
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?: bool (default true), composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}",
|
||
has_text_or_frames,
|
||
|s, p| format_paras(s, p.get("attrs").unwrap_or(p))
|
||
),
|
||
cmd!("type.align", "Align", [], None, "{align: left|center|right|leftJustified|…}", has_text_or_frames, |s, p| {
|
||
format_paras(s, &json!({"align": p.get("align").cloned().unwrap_or(json!("left"))}))
|
||
}),
|
||
cmd!("type.alignLeft", "Align Left", [], Some("Cmd+Shift+L"), "{}", has_text_or_frames, |s, _| format_paras(s, &json!({"align": "left"}))),
|
||
cmd!("type.alignCenter", "Align Center", [], Some("Cmd+Shift+C"), "{}", has_text_or_frames, |s, _| format_paras(
|
||
s,
|
||
&json!({"align": "center"})
|
||
)),
|
||
cmd!("type.alignRight", "Align Right", [], Some("Cmd+Shift+R"), "{}", has_text_or_frames, |s, _| format_paras(s, &json!({"align": "right"}))),
|
||
cmd!("type.justify", "Justify Left", [], Some("Cmd+Shift+J"), "{}", has_text_or_frames, |s, _| format_paras(
|
||
s,
|
||
&json!({"align": "leftJustified"})
|
||
)),
|
||
cmd!(
|
||
"type.bold",
|
||
"Bold",
|
||
[],
|
||
Some("Cmd+Shift+B"),
|
||
"{on?: bool} — the family's bold style, keeping italic (style linking); toggles when `on` is left out",
|
||
has_text_or_frames,
|
||
|s, p| link_style(s, p, true)
|
||
),
|
||
cmd!(
|
||
"type.italic",
|
||
"Italic",
|
||
[],
|
||
Some("Cmd+Shift+I"),
|
||
"{on?: bool} — the family's italic style, keeping the weight (style linking); toggles when `on` is left out",
|
||
has_text_or_frames,
|
||
|s, p| link_style(s, p, false)
|
||
),
|
||
cmd!("type.underline", "Underline", [], Some("Cmd+Shift+U"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"underline": true}))),
|
||
cmd!("type.allCaps", "All Caps", [], Some("Cmd+Shift+K"), "{}", has_text_or_frames, |s, _| format_chars(
|
||
s,
|
||
&json!({"capitalization": "allCaps"})
|
||
)),
|
||
cmd!(
|
||
"type.tateChuYoko",
|
||
"Tate-Chu-Yoko",
|
||
[],
|
||
None,
|
||
"{on?: bool} — set the selected text horizontally within one em of a vertical line (toggles by default)",
|
||
has_text_or_frames,
|
||
|s, p| {
|
||
let on = match p.get("on").and_then(Value::as_bool) {
|
||
Some(v) => v,
|
||
None => !format_targets(s).first().is_some_and(|t| {
|
||
s.doc().ok().and_then(|d| d.doc.text_story(t.story, t.cell)).is_some_and(|st| {
|
||
st.char_format_at(if t.range.is_empty() { t.range.start } else { t.range.start + 1 }).over.tate_chu_yoko == Some(true)
|
||
})
|
||
}),
|
||
};
|
||
format_chars(s, &json!({"tate_chu_yoko": on}))
|
||
}
|
||
),
|
||
cmd!(
|
||
"type.ruby",
|
||
"Ruby",
|
||
[],
|
||
None,
|
||
"{text?, type?: group|perCharacter, alignment?: left|center|right|fullJustify|jis|equalAki|oneAki, position?: aboveRight|belowLeft, xOffset?, yOffset? (pt), font?, fontStyle? (\"\" = the text's), size? (pt; null = half the text size), xScale?, yScale? (%), openTypePro?: bool, autoTcyDigits?: 0-9, autoTcyIncludeRoman?: bool, autoTcyAutoScale?: bool, overhang?: none|oneRuby|halfRuby|oneChar|halfChar|noLimit, parentSpacing?: noAdjustment|bothSides|aki121|equalAki|fullJustify, autoAlign?: bool, autoScaling?: bool, scalingPercent? (%), fill?, stroke? (swatch; \"\" = the text's), fillTint?, strokeTint? (%; null = the text's), strokeWeight? (pt; null = the text's), overprintFill?, overprintStroke?: auto|on|off} — the reading set over the selected text and how it is placed; empty text removes it. Per-character ruby separates the readings with U+3000",
|
||
has_text_or_frames,
|
||
ruby_cmd
|
||
),
|
||
cmd!(
|
||
"type.kenten",
|
||
"Kenten",
|
||
[],
|
||
None,
|
||
"{on?: bool, kind?: sesameDot|whiteSesameDot|fisheye|blackCircle|smallBlackCircle|bullseye|blackTriangle|whiteTriangle|whiteCircle|smallWhiteCircle|custom, character?: text (custom kind), font?: family (custom kind), fontStyle?, size?: pt (null: half the text size), distance?: pt, position?: aboveRight|belowLeft, alignment?: center|start, xScale?: %, yScale?: %, fill?: swatch (\"\": the text's), fillTint?: %, stroke?: swatch, strokeTint?: %, strokeWeight?: pt, overprintFill?: auto|on|off, overprintStroke?: auto|on|off} — emphasis marks beside the selected characters (toggles by default; any setting turns them on)",
|
||
has_text_or_frames,
|
||
kenten_cmd
|
||
),
|
||
cmd!(
|
||
"type.shatai",
|
||
"Shatai",
|
||
[],
|
||
None,
|
||
"{magnification?: % (0 = off), angle?: degrees, adjustRotation?: bool, adjustTsume?: bool} — slant the selected characters by compressing them across a direction",
|
||
has_text_or_frames,
|
||
shatai_cmd
|
||
),
|
||
cmd!(
|
||
"type.autoTateChuYoko",
|
||
"Auto Tate-Chu-Yoko",
|
||
[],
|
||
None,
|
||
"{digits?: 0..10 (0 = off), includeRoman?: bool} — in vertical text, set runs of up to `digits` half-width digits (and roman letters) across one em, for the selected paragraphs",
|
||
has_text_or_frames,
|
||
auto_tcy_cmd
|
||
),
|
||
cmd!(
|
||
"type.gridSettings",
|
||
"Grid Settings",
|
||
[],
|
||
None,
|
||
"{alignment?: none|romanBaseline|emTop|emCenter|emBottom|icfTop|icfBottom, firstLineOnly?: bool, gyoudori?: 0..100 (0 = auto), paragraphGyoudori?: bool} — how the selected paragraphs sit on the baseline grid",
|
||
has_text_or_frames,
|
||
grid_settings_cmd
|
||
),
|
||
cmd!(
|
||
"type.rotateRoman",
|
||
"Rotate Roman Characters in Vertical Text",
|
||
[],
|
||
None,
|
||
"{on?: bool} — half-width characters stand upright in vertical text, for the selected paragraphs (toggles by default)",
|
||
has_text_or_frames,
|
||
|s, p| para_toggle(s, p, "type.rotateRoman", "rotateRoman", |pp| pp.rotate_roman)
|
||
),
|
||
cmd!(
|
||
"type.romanWordBreak",
|
||
"Roman Word Break",
|
||
[],
|
||
None,
|
||
"{on?: bool} — roman words in the selected paragraphs may break between any two letters, without a hyphen (toggles by default)",
|
||
has_text_or_frames,
|
||
|s, p| para_toggle(s, p, "type.romanWordBreak", "romanWordBreak", |pp| pp.roman_word_break)
|
||
),
|
||
cmd!(
|
||
"type.warichu",
|
||
"Warichu",
|
||
[],
|
||
None,
|
||
"{on?: bool, lines?: int, size?: number, lineSpacing?: number, align?: auto|left|center|right|justify, charsBeforeBreak?: int, charsAfterBreak?: int} — set the selection as an inline note in smaller lines inside one line height (toggles by default)",
|
||
has_text_or_frames,
|
||
warichu_cmd
|
||
),
|
||
cmd!(
|
||
"type.storyDirection",
|
||
"Story Direction",
|
||
[],
|
||
None,
|
||
"{vertical: bool} — the stories of the text selection or the selected frames: vertical lines run top to bottom and follow each other right to left, in every frame of the thread",
|
||
has_text_or_frames,
|
||
story_direction
|
||
),
|
||
cmd!(
|
||
"type.kernStep",
|
||
"Kerning/Tracking Step",
|
||
[],
|
||
None,
|
||
"{steps?: n (default 1; negative tightens), amount?: 1/1000 em (in place of steps × the document's kerningIncrement)} — with a caret, the manual kerning between the characters at the caret (from the pair's manual value, else 0); with a selection, the tracking of the selected text",
|
||
has_text_or_frames,
|
||
kern_step
|
||
),
|
||
cmd!("type.sizeUp", "Increase Point Size", [], Some("Cmd+Shift+."), "{}", has_text_or_frames, |s, _| step_size(s, 2.0)),
|
||
cmd!("type.sizeDown", "Decrease Point Size", [], Some("Cmd+Shift+,"), "{}", has_text_or_frames, |s, _| step_size(s, -2.0)),
|
||
cmd!(
|
||
"type.changeCase",
|
||
"Change Case",
|
||
["Type"],
|
||
None,
|
||
"{case: upper|lower|title|sentence} — the selected text, or the stories of selected frames (formatting kept)",
|
||
has_text_or_frames,
|
||
change_case
|
||
),
|
||
cmd!(
|
||
"type.openType",
|
||
"OpenType",
|
||
[],
|
||
None,
|
||
"{feature?: dlig|frac|ordn|swsh|titl|calt|zero|ssNN|any tag, on?: bool (default: toggle), figures?: tabularLining|proportionalOldstyle|proportionalLining|tabularOldstyle|default, stylisticSets?: [1–20]} → {features, figures}",
|
||
has_text_or_frames,
|
||
open_type
|
||
),
|
||
cmd!(
|
||
"style.breakLink",
|
||
"Break Link to Style",
|
||
[],
|
||
None,
|
||
"{kind: paragraph|character} — the selected text keeps its look as local formatting, with [No Paragraph Style] / [None]",
|
||
has_text_or_frames,
|
||
break_link
|
||
),
|
||
cmd!(
|
||
query "type.selectionAttrs",
|
||
"Selection Attributes",
|
||
[],
|
||
None,
|
||
"{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters)",
|
||
has_doc,
|
||
selection_attrs
|
||
),
|
||
]
|
||
}
|
||
|
||
pub(crate) fn story_of(s: &Session, p: &Value) -> Option<StoryId> {
|
||
let st = s.active()?;
|
||
if let Some(v) = p.get("story").and_then(Value::as_u64) {
|
||
return Some(StoryId(v));
|
||
}
|
||
let frame = id_param(p, "frame").or_else(|| st.selection.items.first().copied());
|
||
if let Some(f) = frame {
|
||
return st.doc.item(f).and_then(|i| i.text_frame()).map(|t| t.story);
|
||
}
|
||
st.selection.text.map(|t| t.story)
|
||
}
|
||
|
||
/// Story byte at spread point `pt` in text frame `frame` (in a table cell's story when the point is in a cell).
|
||
fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Option<CellAddr>)> {
|
||
hit_byte_with(s, frame, pt, compose::hit)
|
||
}
|
||
|
||
/// [`hit_byte`] with the hit test: [`compose::hit`] (caret position) or [`compose::hit_char`]
|
||
/// (the character under the point).
|
||
fn hit_byte_with(
|
||
s: &Session,
|
||
frame: ItemId,
|
||
pt: Point,
|
||
hit: fn(&compose::ComposedStory, usize, Point) -> Option<usize>,
|
||
) -> Option<(StoryId, usize, Option<CellAddr>)> {
|
||
let st = s.active()?;
|
||
let loc = st.doc.find(frame)?;
|
||
let it = st.doc.item_at(&loc)?;
|
||
let sid = it.text_frame()?.story;
|
||
let xf = st.doc.parent_xf(&loc) * st.doc.text_xf(it);
|
||
let inner = xf.inverse() * pt;
|
||
let cs = s.cache.get(&st.doc, sid, None);
|
||
let fi = cs.frames.iter().position(|f| f.frame == frame)?;
|
||
// Type on a path: the click lands on the line where it is along the path.
|
||
let inner = cs.frames.get(fi).and_then(|ft| compose::PathTextMap::new(it, ft)).map_or(inner, |m| m.to_line(inner));
|
||
if let Some((table, row, col, b)) = compose::hit_cell_with(&cs, fi, inner, hit) {
|
||
return Some((sid, b, Some(CellAddr { table, row, col })));
|
||
}
|
||
if let Some((id, b)) = compose::hit_note_with(&cs, fi, inner, hit) {
|
||
return Some((sid, b, Some(CellAddr::footnote(id))));
|
||
}
|
||
let b = hit(&cs, fi, inner).unwrap_or(0);
|
||
Some((sid, b, None))
|
||
}
|
||
|
||
/// Select the word, line, paragraph or story at `point` in `frame` (multiple clicks).
|
||
fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result<Value> {
|
||
const ID: &str = "text.selectAt";
|
||
let frame = id_param(p, "frame").ok_or_else(|| bad(ID, "missing frame"))?;
|
||
let pt = point_param(p, "point").ok_or_else(|| bad(ID, "missing point"))?;
|
||
let (sid, b, cell) = hit_byte_with(s, frame, pt, compose::hit_char).ok_or_else(|| bad(ID, "not a text frame"))?;
|
||
let st = s.doc()?;
|
||
let story = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?;
|
||
let text = story.text.as_str();
|
||
let b = floor_char_boundary(text, b);
|
||
let (a, e) = match unit {
|
||
"word" => word_or_spaces_at(text, b),
|
||
"line" => {
|
||
let cs = s.cache.get(&st.doc, sid, None);
|
||
line_at(&text_composition(cs, cell), b).unwrap_or((b, b))
|
||
}
|
||
// The paragraph with its return (the last paragraph has none).
|
||
"paragraph" => story.para_ranges().get(story.para_at(b)).map_or((b, b), |r| (r.start, (r.end + 1).min(text.len()))),
|
||
"story" => (0, text.len()),
|
||
other => return Err(bad(ID, format!("unknown unit `{other}` (word, line, paragraph or story)"))),
|
||
};
|
||
s.text_drag = false;
|
||
s.typing_format = None;
|
||
let st = s.doc_mut()?;
|
||
st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: e, frame: Some(frame), cell });
|
||
st.bump_revision();
|
||
Ok(json!({"story": sid.0, "anchor": a, "focus": e}))
|
||
}
|
||
|
||
/// The word at byte `i`; on spaces the run of spaces; on anything else that one character
|
||
/// (grapheme cluster). At the end of a paragraph, what ends it. Grapheme clusters are never split:
|
||
/// combining marks, viramas, variation selectors and emoji ZWJ sequences stay with their base.
|
||
fn word_or_spaces_at(s: &str, i: usize) -> (usize, usize) {
|
||
use unicode_segmentation::UnicodeSegmentation;
|
||
let i = floor_char_boundary(s, i.min(s.len()));
|
||
// Clusters never span a paragraph return, so the paragraph is enough.
|
||
let ps = s[..i].rfind('\n').map_or(0, |k| k + 1);
|
||
let pe = s[i..].find('\n').map_or(s.len(), |k| i + k);
|
||
let g: Vec<(usize, &str)> = s[ps..pe].grapheme_indices(true).collect();
|
||
let Some(k) = g.iter().rposition(|(o, _)| ps + o <= i) else { return (i, i) };
|
||
let kind = |c: &str| match c.chars().next() {
|
||
Some(c) if is_word(c) => 1,
|
||
Some(c) if c.is_whitespace() => 2,
|
||
_ => 0,
|
||
};
|
||
let span = |a: usize, e: usize| g.get(a).zip(g.get(e)).map_or((i, i), |((oa, _), (oe, ge))| (ps + oa, ps + oe + ge.len()));
|
||
let kd = g.get(k).map_or(0, |(_, c)| kind(c));
|
||
if kd == 0 {
|
||
return span(k, k);
|
||
}
|
||
let mut a = k;
|
||
while a > 0 && g.get(a - 1).is_some_and(|(_, c)| kind(c) == kd) {
|
||
a -= 1;
|
||
}
|
||
let mut e = k;
|
||
while g.get(e + 1).is_some_and(|(_, c)| kind(c) == kd) {
|
||
e += 1;
|
||
}
|
||
span(a, e)
|
||
}
|
||
|
||
/// The composition whose lines hold a story's text: the story's own, a table cell's or a footnote's.
|
||
fn text_composition(cs: std::sync::Arc<compose::ComposedStory>, cell: Option<CellAddr>) -> std::sync::Arc<compose::ComposedStory> {
|
||
match cell {
|
||
Some(c) if c.footnote_id().is_some() => compose::find_note(&cs, c.row as u64).map(|(_, n)| n.text.clone()).unwrap_or(cs),
|
||
Some(c) => compose::find_cell(&cs, c.table, c.row, c.col).map(|(_, _, pc)| pc.text.clone()).unwrap_or(cs),
|
||
None => cs,
|
||
}
|
||
}
|
||
|
||
/// The composed line holding the character at byte `pos`.
|
||
fn line_at(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> {
|
||
let lines = || cs.frames.iter().flat_map(|f| f.lines.iter());
|
||
lines().find(|l| l.range.contains(&pos)).map(|l| (l.range.start, l.range.end)).or_else(|| line_of(cs, pos))
|
||
}
|
||
|
||
fn place(s: &mut Session, p: &Value, extend: bool) -> Result<Value> {
|
||
let frame = id_param(p, "frame").ok_or_else(|| bad("text.placeCaret", "missing frame"))?;
|
||
let pt = point_param(p, "point").unwrap_or(Point::ZERO);
|
||
// Empty frames become text frames.
|
||
let needs_convert = s.doc()?.doc.item(frame).is_some_and(|i| matches!(i.content, Content::Unassigned));
|
||
if needs_convert {
|
||
s.execute("object.content", &json!({"ids": [frame.0], "type": "text"}))?;
|
||
}
|
||
let (sid, b, cell) = hit_byte(s, frame, pt).ok_or_else(|| bad("text.placeCaret", "not a text frame"))?;
|
||
// Drag and drop: a press inside the selected text keeps it (to be dragged).
|
||
if !extend {
|
||
s.text_drag = s.doc()?.selection.text.is_some_and(|t| t.story == sid && t.cell == cell && !t.range().is_empty() && t.range().contains(&b));
|
||
if s.text_drag {
|
||
return Ok(json!({"story": sid.0, "dragging": true}));
|
||
}
|
||
} else if s.text_drag {
|
||
return Ok(json!({"story": sid.0, "dragging": true}));
|
||
}
|
||
s.typing_format = None;
|
||
let st = s.doc_mut()?;
|
||
// Dragging from one cell into another selects cells.
|
||
if extend
|
||
&& let (Some(t), Some(c)) = (st.selection.text, cell)
|
||
&& let Some(a) = t.cell
|
||
&& t.story == sid
|
||
&& a.table == c.table
|
||
&& (a.row, a.col) != (c.row, c.col)
|
||
{
|
||
let range = designcraft_doc::CellRange::new(a.row, a.col, c.row, c.col);
|
||
st.selection.cells = Some(designcraft_doc::TableSel { story: sid, table: c.table, range });
|
||
st.bump_revision();
|
||
return Ok(json!({"story": sid.0, "cells": range}));
|
||
}
|
||
let t = match (extend, st.selection.text) {
|
||
(true, Some(t)) if t.story == sid && t.cell == cell => TextSel { focus: b, frame: Some(frame), ..t },
|
||
_ => TextSel { story: sid, anchor: b, focus: b, frame: Some(frame), cell },
|
||
};
|
||
st.selection = Selection::text(t);
|
||
st.bump_revision();
|
||
Ok(json!({"story": sid.0, "pos": b, "cell": cell}))
|
||
}
|
||
|
||
fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||
if !std::mem::take(&mut s.text_drag) {
|
||
return ok();
|
||
}
|
||
let frame = id_param(p, "frame").ok_or_else(|| bad("text.release", "missing frame"))?;
|
||
let pt = point_param(p, "point").unwrap_or(Point::ZERO);
|
||
if !p.get("moved").and_then(Value::as_bool).unwrap_or(false) {
|
||
// A click in the selection: the caret goes there.
|
||
s.doc_mut()?.selection.text = None;
|
||
return place(s, &json!({"frame": frame.0, "point": [pt.x, pt.y]}), false);
|
||
}
|
||
let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false);
|
||
let Some(t) = s.doc()?.selection.text else { return ok() };
|
||
let Some((sid, b, cell)) = hit_byte(s, frame, pt) else { return ok() };
|
||
let r = t.range();
|
||
if sid != t.story || cell != t.cell || (r.contains(&b) && !copy) {
|
||
return ok();
|
||
}
|
||
s.edit(|d, sel| {
|
||
let st = d.text_story_mut(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?;
|
||
let piece = st.extract(r.clone());
|
||
let mut at = b;
|
||
if !copy {
|
||
st.delete(r.clone());
|
||
if at > r.start {
|
||
at -= r.len().min(at - r.start);
|
||
}
|
||
}
|
||
let end = st.insert_story(at, &piece);
|
||
sel.text = Some(TextSel { story: sid, anchor: at, focus: end, frame: Some(frame), cell });
|
||
Ok(json!({"moved": piece.text.len(), "at": at}))
|
||
})
|
||
}
|
||
|
||
// Each command edits a copy-on-write Story snapshot. Reserving its known final text growth
|
||
// avoids retaining geometric String spare capacity in every independent undo snapshot.
|
||
// General Story insertion keeps its existing amortized-growth policy for mutable builders.
|
||
fn reserve_command_text(st: &mut designcraft_doc::Story, additional: usize) -> Result<()> {
|
||
st.text.try_reserve_exact(additional).map_err(|e| crate::EngineError::Other(format!("could not reserve text insertion: {e}")))
|
||
}
|
||
|
||
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`).
|
||
if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some()) {
|
||
return super::table::step_cell(s, true);
|
||
}
|
||
let raw = p.get("raw").and_then(Value::as_bool).unwrap_or(false);
|
||
let text = if s.prefs.typographers_quotes && !raw { smart_quotes(s, &text) } else { text };
|
||
let tracking = s.doc()?.doc.settings.track_changes;
|
||
let autocorrect = (s.prefs.autocorrect && !tracking && !raw).then(|| s.prefs.autocorrect_list.clone());
|
||
let typing = typing_format(s).cloned();
|
||
// A preview (IME marked text) leaves the typing format to the text typed in the end.
|
||
if s.active().is_none_or(|d| d.interaction.is_none()) {
|
||
s.typing_format = None;
|
||
}
|
||
s.edit(|d, sel| {
|
||
let t = sel.text.ok_or_else(|| bad("text.insert", "no insertion point"))?;
|
||
let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
|
||
let r = t.range();
|
||
let mut r = r.start.min(st.len())..r.end.min(st.len());
|
||
// Between a break character and its return is the end of that paragraph: typing there goes
|
||
// to the start of the next one.
|
||
if r.is_empty()
|
||
&& st.text.get(r.start..).is_some_and(|t| t.starts_with('\n'))
|
||
&& st.text.get(..r.start).and_then(|t| t.chars().next_back()).is_some_and(designcraft_doc::is_break_char)
|
||
{
|
||
r = r.start + 1..r.start + 1;
|
||
}
|
||
// Autocorrect: a word ended by a space or punctuation is looked up.
|
||
if let Some(list) = &autocorrect
|
||
&& r.is_empty()
|
||
&& text.chars().count() == 1
|
||
&& text.chars().all(|c| c.is_whitespace() || c.is_ascii_punctuation())
|
||
{
|
||
let before = &st.text[..r.start];
|
||
let ws = before.char_indices().rev().take_while(|(_, c)| c.is_alphabetic()).last().map(|(i, _)| i);
|
||
if let Some(ws) = ws {
|
||
let word = &before[ws..];
|
||
if let Some((_, to)) = list.iter().find(|(from, _)| from.eq_ignore_ascii_case(word)) {
|
||
let to = if word.chars().next().is_some_and(char::is_uppercase) {
|
||
let mut c = to.chars();
|
||
c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default()
|
||
} else {
|
||
to.clone()
|
||
};
|
||
st.replace(ws..r.start, &to);
|
||
r = ws + to.len()..ws + to.len();
|
||
}
|
||
}
|
||
}
|
||
// A column, frame or page break ends its paragraph.
|
||
let mut text = if text.contains(designcraft_doc::is_break_char) {
|
||
designcraft_doc::end_paragraphs_at_breaks(&text, st.text.get(r.end..).and_then(|t| t.chars().next()))
|
||
} else {
|
||
text.clone()
|
||
};
|
||
// Typing next to a table anchor starts a paragraph of its own.
|
||
let mut trail = 0;
|
||
if t.cell.is_none() && !text.is_empty() && st.table_at(r.start).is_some() {
|
||
let pi = st.para_at(r.start);
|
||
let pr = st.para_ranges()[pi].clone();
|
||
let anchor_at = pr.start + st.text[pr.clone()].find(designcraft_doc::TABLE_ANCHOR).unwrap_or(0);
|
||
if r.start <= anchor_at {
|
||
if !text.ends_with('\n') {
|
||
text.push('\n');
|
||
trail = 1;
|
||
}
|
||
} else if !text.starts_with('\n') {
|
||
text.insert(0, '\n');
|
||
}
|
||
}
|
||
if tracking && t.cell.is_none() {
|
||
// Track Changes: the replaced text is marked deleted, the typing inserted after it.
|
||
let at = super::changes::mark_deleted(st, r.clone());
|
||
reserve_command_text(st, text.len())?;
|
||
let mut fmt = st.char_format_at(at).clone();
|
||
if let Some(f) = &typing {
|
||
apply_typing_format(&mut fmt, f);
|
||
}
|
||
fmt.over.change = Some(designcraft_doc::ChangeMark::Inserted);
|
||
st.insert_with(at, &text, fmt);
|
||
let pos = at + text.len() - trail;
|
||
sel.text = Some(TextSel { anchor: pos, focus: pos, ..t });
|
||
return Ok(json!({"pos": pos}));
|
||
}
|
||
let a = floor_char_boundary(&st.text, r.start.min(st.len()));
|
||
let b = floor_char_boundary(&st.text, r.end.min(st.len())).max(a);
|
||
reserve_command_text(st, text.len().saturating_sub(b - a))?;
|
||
st.replace(r.clone(), &text);
|
||
if let Some(f) = &typing
|
||
&& !text.is_empty()
|
||
{
|
||
st.format_chars(r.start..r.start + text.len(), |fmt| apply_typing_format(fmt, f));
|
||
}
|
||
let pos = r.start + text.len() - trail;
|
||
sel.text = Some(TextSel { anchor: pos, focus: pos, ..t });
|
||
Ok(json!({"pos": pos}))
|
||
})
|
||
}
|
||
|
||
fn smart_quotes(s: &Session, text: &str) -> String {
|
||
if !text.contains(['"', '\'']) {
|
||
return text.to_string();
|
||
}
|
||
let prev = s
|
||
.active()
|
||
.and_then(|st| {
|
||
let t = st.selection.text?;
|
||
let story = st.doc.text_story(t.story, t.cell)?;
|
||
story.text[..t.range().start.min(story.len())].chars().last()
|
||
})
|
||
.unwrap_or(' ');
|
||
// The language at the insertion point picks the quote marks.
|
||
let language = s
|
||
.active()
|
||
.and_then(|st| {
|
||
let t = st.selection.text?;
|
||
let story = st.doc.text_story(t.story, t.cell)?;
|
||
let pos = t.range().start.min(story.len());
|
||
let pi = story.para_at(pos).min(story.paras.len().saturating_sub(1));
|
||
let base = st.doc.styles.resolve_para(story.paras.get(pi)?).1;
|
||
Some(st.doc.styles.resolve_char(&base, story.char_format_at(pos)).language)
|
||
})
|
||
.unwrap_or_default();
|
||
let [dq_open, dq_close, sq_open, sq_close] = quote_marks(&language);
|
||
let mut out = String::with_capacity(text.len());
|
||
let mut last = prev;
|
||
for c in text.chars() {
|
||
let open = last.is_whitespace() || matches!(last, '(' | '[' | '{' | '\u{2014}' | '\u{2013}');
|
||
let r = match c {
|
||
'"' => {
|
||
if open {
|
||
dq_open
|
||
} else {
|
||
dq_close
|
||
}
|
||
}
|
||
'\'' => {
|
||
if open {
|
||
sq_open
|
||
} else {
|
||
// An apostrophe inside a word stays an apostrophe.
|
||
if last.is_alphanumeric() { '\u{2019}' } else { sq_close }
|
||
}
|
||
}
|
||
c => c,
|
||
};
|
||
out.push(r);
|
||
last = c;
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Typographer's quotes of a language (a name or locale code, see
|
||
/// [`designcraft_doc::language_tag`]): [double open, double close, single open, single close].
|
||
pub fn quote_marks(language: &str) -> [char; 4] {
|
||
const ENGLISH: [char; 4] = ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}'];
|
||
let Some(tag) = designcraft_doc::language_tag(language) else { return ENGLISH };
|
||
// Swiss German, French and Italian use guillemets.
|
||
if tag.ends_with("-CH") {
|
||
return ['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}'];
|
||
}
|
||
match tag {
|
||
"zh-Hans" => return ENGLISH,
|
||
"zh" | "zh-Hant" => return ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}'],
|
||
_ => {}
|
||
}
|
||
match designcraft_doc::language_subtag(tag) {
|
||
"de" | "cs" | "sk" | "bg" | "lt" => ['\u{201E}', '\u{201C}', '\u{201A}', '\u{2018}'],
|
||
"fr" | "ru" | "uk" | "no" | "nb" | "nn" | "el" => ['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}'],
|
||
"es" | "it" | "pt" | "ca" => ['\u{00AB}', '\u{00BB}', '\u{201C}', '\u{201D}'],
|
||
"nl" | "pl" | "ro" | "hu" | "hr" => ['\u{201E}', '\u{201D}', '\u{201A}', '\u{2019}'],
|
||
"sv" | "fi" => ['\u{201D}', '\u{201D}', '\u{2019}', '\u{2019}'],
|
||
"da" => ['\u{00BB}', '\u{00AB}', '\u{203A}', '\u{2039}'],
|
||
"ja" => ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}'],
|
||
_ => ENGLISH,
|
||
}
|
||
}
|
||
|
||
pub(crate) fn delete_selection(s: &mut Session) -> Result<Value> {
|
||
let tracking = s.doc()?.doc.settings.track_changes;
|
||
s.edit(|d, sel| {
|
||
let t = sel.text.ok_or_else(|| bad("text.delete", "no text"))?;
|
||
let r = t.range();
|
||
if let Some(st) = d.text_story_mut(t.story, t.cell) {
|
||
if tracking && t.cell.is_none() {
|
||
super::changes::mark_deleted(st, r.clone());
|
||
} else {
|
||
st.delete(r.clone());
|
||
}
|
||
}
|
||
sel.text = Some(TextSel { anchor: r.start, focus: r.start, ..t });
|
||
ok()
|
||
})
|
||
}
|
||
|
||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let forward = bool_or(p, "forward", false);
|
||
let word = bool_or(p, "word", false);
|
||
let tracking = s.doc()?.doc.settings.track_changes;
|
||
s.edit(|d, sel| {
|
||
let t = sel.text.ok_or_else(|| bad("text.delete", "no text"))?;
|
||
let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
|
||
let r = t.range();
|
||
let r = if !r.is_empty() {
|
||
r
|
||
} else if forward {
|
||
let end = if word { next_word(&st.text, r.start) } else { next_char(&st.text, r.start) };
|
||
r.start..end
|
||
} else {
|
||
let start = if word { prev_word(&st.text, r.start) } else { prev_char(&st.text, r.start) };
|
||
start..r.start
|
||
};
|
||
let r = with_break_pair(&st.text, r);
|
||
if tracking && t.cell.is_none() {
|
||
// Marked, not removed: the caret steps over the deleted text.
|
||
let at = super::changes::mark_deleted(st, r.clone());
|
||
let pos = if forward { at.max(r.end.min(st.len())) } else { r.start.min(at) };
|
||
sel.text = Some(TextSel { anchor: pos, focus: pos, ..t });
|
||
return ok();
|
||
}
|
||
st.delete(r.clone());
|
||
sel.text = Some(TextSel { anchor: r.start, focus: r.start, ..t });
|
||
ok()
|
||
})
|
||
}
|
||
|
||
/// A break character and the return after it end one paragraph: deleting either deletes both.
|
||
fn with_break_pair(text: &str, r: std::ops::Range<usize>) -> std::ops::Range<usize> {
|
||
let Some(del) = text.get(r.clone()) else { return r };
|
||
let mut chars = del.chars();
|
||
match (chars.next(), chars.next()) {
|
||
(Some('\n'), None) => match text.get(..r.start).and_then(|t| t.chars().next_back()) {
|
||
Some(b) if designcraft_doc::is_break_char(b) => r.start.saturating_sub(b.len_utf8())..r.end,
|
||
_ => r,
|
||
},
|
||
(Some(b), None) if designcraft_doc::is_break_char(b) && text.get(r.end..).is_some_and(|t| t.starts_with('\n')) => r.start..r.end + 1,
|
||
_ => r,
|
||
}
|
||
}
|
||
|
||
pub fn next_char(s: &str, i: usize) -> usize {
|
||
s[i.min(s.len())..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(s.len())
|
||
}
|
||
pub fn prev_char(s: &str, i: usize) -> usize {
|
||
s[..i.min(s.len())].chars().next_back().map(|c| i - c.len_utf8()).unwrap_or(0)
|
||
}
|
||
fn is_word(c: char) -> bool {
|
||
c.is_alphanumeric() || c == '\'' || c == '’'
|
||
}
|
||
pub fn next_word(s: &str, i: usize) -> usize {
|
||
let mut j = i;
|
||
let mut seen = false;
|
||
for (k, c) in s[i..].char_indices() {
|
||
if is_word(c) {
|
||
seen = true;
|
||
} else if seen {
|
||
return i + k;
|
||
}
|
||
j = i + k + c.len_utf8();
|
||
}
|
||
j
|
||
}
|
||
pub fn prev_word(s: &str, i: usize) -> usize {
|
||
let mut seen = false;
|
||
for (k, c) in s[..i].char_indices().rev() {
|
||
if is_word(c) {
|
||
seen = true;
|
||
} else if seen {
|
||
return k + c.len_utf8();
|
||
}
|
||
}
|
||
0
|
||
}
|
||
/// Type ▸ Story Direction.
|
||
fn story_direction(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?;
|
||
let st = s.doc()?;
|
||
let mut stories: Vec<StoryId> = st.selection.text.map(|t| t.story).into_iter().chain(st.selection.cells.map(|c| c.story)).collect();
|
||
for id in &st.selection.items {
|
||
if let Some(tf) = st.doc.item(*id).and_then(|i| i.text_frame())
|
||
&& !stories.contains(&tf.story)
|
||
{
|
||
stories.push(tf.story);
|
||
}
|
||
}
|
||
s.edit(|d, _| {
|
||
set_story_direction(d, &stories, vertical);
|
||
Ok(json!({"stories": stories.len()}))
|
||
})
|
||
}
|
||
|
||
/// Set the direction of `stories` (all their frames turn together).
|
||
pub(crate) fn set_story_direction(d: &mut designcraft_doc::Document, stories: &[StoryId], vertical: bool) {
|
||
for sid in stories {
|
||
if let Some(st) = d.story_mut(*sid)
|
||
&& st.vertical != vertical
|
||
{
|
||
st.vertical = vertical;
|
||
st.rev = st.rev.wrapping_add(1);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let dir = str_param(p, "dir").unwrap_or("right").to_string();
|
||
let extend = bool_or(p, "extend", false);
|
||
let word = bool_or(p, "word", false);
|
||
let st = s.doc()?;
|
||
let t = st.selection.text.ok_or_else(|| bad("text.move", "no caret"))?;
|
||
let story = st.doc.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
|
||
let text = story.text.clone();
|
||
// In a cell, lines come from the cell's own composition.
|
||
let cs = text_composition(s.cache.get(&st.doc, t.story, None), t.cell);
|
||
let pos = t.focus.min(text.len());
|
||
// Vertical lines run top to bottom and follow each other right to left: Down and Up move along
|
||
// the line (Right and Left of horizontal text), Left and Right to the next and previous line.
|
||
let vertical_text = compose::caret(&cs, pos).and_then(|(fi, ..)| cs.frames.get(fi)).is_some_and(|f| f.vertical);
|
||
let dir = match (vertical_text, dir.as_str()) {
|
||
(true, "down") => "right",
|
||
(true, "up") => "left",
|
||
(true, "left") => "down",
|
||
(true, "right") => "up",
|
||
(_, d) => d,
|
||
};
|
||
let collapse_to = |left: bool| if left { t.range().start } else { t.range().end };
|
||
let new = match dir {
|
||
"left" if !extend && !t.is_caret() => collapse_to(true),
|
||
"right" if !extend && !t.is_caret() => collapse_to(false),
|
||
"left" | "right" => {
|
||
// Lines with right-to-left text move as drawn; at a line's edge, or by words, a
|
||
// right-to-left line's "left" is forward in the text.
|
||
let left = dir == "left";
|
||
let visual = if word { None } else { compose::visual_step(&cs, pos, left) };
|
||
match visual {
|
||
Some(p) => p,
|
||
None => {
|
||
let forward = left == compose::line_rtl(&cs, pos);
|
||
match (forward, word) {
|
||
(true, true) => next_word(&text, pos),
|
||
(true, false) => next_char(&text, pos),
|
||
(false, true) => prev_word(&text, pos),
|
||
(false, false) => prev_char(&text, pos),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
"up" | "down" => vertical(&cs, pos, dir == "up").unwrap_or(pos),
|
||
"lineStart" => line_of(&cs, pos).map(|l| l.0).unwrap_or(0),
|
||
"lineEnd" => line_of(&cs, pos).map(|l| l.1).unwrap_or(text.len()),
|
||
"storyStart" => 0,
|
||
"storyEnd" => text.len(),
|
||
other => return Err(bad("text.move", format!("unknown dir `{other}`"))),
|
||
};
|
||
s.typing_format = None;
|
||
let stm = s.doc_mut()?;
|
||
stm.selection.text = Some(if extend { TextSel { focus: new, ..t } } else { TextSel { anchor: new, focus: new, ..t } });
|
||
stm.revision += 1;
|
||
Ok(json!({"pos": new}))
|
||
}
|
||
|
||
fn line_of(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> {
|
||
cs.frames.iter().flat_map(|f| f.lines.iter()).find(|l| pos >= l.range.start && pos <= l.range.end).map(|l| (l.range.start, l.range.end))
|
||
}
|
||
|
||
fn vertical(cs: &compose::ComposedStory, pos: usize, up: bool) -> Option<usize> {
|
||
let (fi, x, caret_y, _, _) = compose::caret(cs, pos)?;
|
||
let lines: Vec<(usize, &compose::Line)> = cs.frames.iter().enumerate().flat_map(|(i, f)| f.lines.iter().map(move |l| (i, l))).collect();
|
||
// Match the line by the bytes it owns. A warichu caret sits on a small-line baseline, so it
|
||
// does not equal the parent line's baseline.
|
||
let cur = lines.iter().position(|(i, l)| *i == fi && pos >= l.range.start && pos <= l.range.end && (pos < l.range.end || l.last_in_para))?;
|
||
let (_, l) = lines.get(cur)?;
|
||
if let Some(y) = compose::adjacent_row(l, x, caret_y, up)
|
||
&& let Some(p) = compose::hit(cs, fi, Point::new(x, y))
|
||
&& p != pos
|
||
{
|
||
return Some(p);
|
||
}
|
||
// The next row: segments of the same line (beside a wrap object) share its baseline.
|
||
let other_row = |(i, t): &&(usize, &compose::Line)| *i != fi || (t.baseline - l.baseline).abs() > 1e-6;
|
||
let (tf, tl) = if up { lines.get(..cur)?.iter().rfind(other_row)? } else { lines.get(cur + 1..)?.iter().find(other_row)? };
|
||
compose::hit(cs, *tf, Point::new(x, tl.baseline - 1.0))
|
||
}
|
||
|
||
/// Where a formatting command applies: a story (or a table cell's story) and a byte range.
|
||
#[derive(Clone, Debug)]
|
||
pub(crate) struct Target {
|
||
pub story: StoryId,
|
||
pub cell: Option<CellAddr>,
|
||
pub range: std::ops::Range<usize>,
|
||
}
|
||
|
||
/// Ranges and stories a formatting command applies to: selected table cells (whole cells), the
|
||
/// text selection, or whole stories of the selected text frames.
|
||
pub(crate) fn format_targets(s: &Session) -> Vec<Target> {
|
||
let Some(st) = s.active() else { return vec![] };
|
||
if let Some(ts) = st.selection.cells
|
||
&& let Some(t) = st.doc.story(ts.story).and_then(|x| x.tables.get(&ts.table))
|
||
{
|
||
let mut v = Vec::new();
|
||
let owners = t.owners();
|
||
for r in ts.range.r0..=ts.range.r1.min(t.nrows().saturating_sub(1)) {
|
||
for c in ts.range.c0..=ts.range.c1.min(t.ncols().saturating_sub(1)) {
|
||
if owners[r * t.ncols() + c] == (r, c) {
|
||
let len = t.cell(r, c).map_or(0, |x| x.text.len());
|
||
v.push(Target { story: ts.story, cell: Some(CellAddr { table: ts.table, row: r, col: c }), range: 0..len });
|
||
}
|
||
}
|
||
}
|
||
return v;
|
||
}
|
||
if let Some(t) = st.selection.text {
|
||
return vec![Target { story: t.story, cell: t.cell, range: t.range() }];
|
||
}
|
||
let mut v: Vec<Target> = Vec::new();
|
||
for id in &st.selection.items {
|
||
if let Some(tf) = st.doc.item(*id).and_then(|i| i.text_frame()) {
|
||
let len = st.doc.story(tf.story).map(|x| x.len()).unwrap_or(0);
|
||
if !v.iter().any(|t| t.story == tf.story) {
|
||
v.push(Target { story: tf.story, cell: None, range: 0..len });
|
||
}
|
||
}
|
||
}
|
||
v
|
||
}
|
||
|
||
/// Character formatting chosen at a bare caret in text: applied to what is typed there next.
|
||
#[derive(Clone, Debug)]
|
||
pub(crate) struct TypingFormat {
|
||
doc: u64,
|
||
at: TextSel,
|
||
attrs: CharAttrs,
|
||
/// Overrides removed (`null` in `type.char`).
|
||
cleared: Vec<String>,
|
||
}
|
||
|
||
/// The typing format for the active document's caret, if it was chosen at this caret.
|
||
fn typing_format(s: &Session) -> Option<&TypingFormat> {
|
||
let st = s.active()?;
|
||
let t = st.selection.text?;
|
||
s.typing_format.as_ref().filter(|f| f.doc == st.uid && f.at.story == t.story && f.at.cell == t.cell && f.at.range() == t.range())
|
||
}
|
||
|
||
fn warichu_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let has_opts = ["lines", "size", "lineSpacing", "align", "charsBeforeBreak", "charsAfterBreak"].iter().any(|k| p.get(k).is_some());
|
||
let on = match p.get("on").and_then(Value::as_bool) {
|
||
Some(v) => v,
|
||
None if has_opts => true,
|
||
None => !format_targets(s).first().is_some_and(|t| {
|
||
s.doc()
|
||
.ok()
|
||
.and_then(|d| d.doc.text_story(t.story, t.cell))
|
||
.is_some_and(|st| st.char_format_at(if t.range.is_empty() { t.range.start } else { t.range.start + 1 }).over.warichu == Some(true))
|
||
}),
|
||
};
|
||
let mut body = serde_json::Map::new();
|
||
body.insert("warichu".into(), json!(on));
|
||
if on {
|
||
if let Some(v) = p.get("lines").and_then(Value::as_u64) {
|
||
body.insert("warichuLines".into(), json!(v.clamp(2, 16)));
|
||
}
|
||
if let Some(v) = p.get("size").and_then(Value::as_f64).filter(|v| v.is_finite()) {
|
||
body.insert("warichuSize".into(), json!(v.clamp(1.0, 1000.0)));
|
||
}
|
||
if let Some(v) = p.get("lineSpacing").and_then(Value::as_f64).filter(|v| v.is_finite()) {
|
||
body.insert("warichuLineSpacing".into(), json!(v.clamp(-10_000.0, 10_000.0)));
|
||
}
|
||
if let Some(v) = p.get("align").and_then(Value::as_str) {
|
||
body.insert("warichuAlignment".into(), json!(v));
|
||
}
|
||
if let Some(v) = p.get("charsBeforeBreak").and_then(Value::as_u64) {
|
||
body.insert("warichuCharsBeforeBreak".into(), json!(v.min(100)));
|
||
}
|
||
if let Some(v) = p.get("charsAfterBreak").and_then(Value::as_u64) {
|
||
body.insert("warichuCharsAfterBreak".into(), json!(v.min(100)));
|
||
}
|
||
}
|
||
format_chars(s, &Value::Object(body))
|
||
}
|
||
|
||
// Kenten, shatai, auto tate-chu-yoko and grid settings: validated settings for the CJK commands.
|
||
|
||
/// Longest font, style or swatch name taken from parameters, in characters.
|
||
const NAME_MAX_CHARS: usize = 256;
|
||
|
||
/// A number parameter: absent or `null` → `None`; anything but a finite number is an error.
|
||
fn finite_param(p: &Value, key: &str, cmd: &str) -> Result<Option<f64>> {
|
||
match p.get(key) {
|
||
None | Some(Value::Null) => Ok(None),
|
||
Some(v) => v.as_f64().filter(|v| v.is_finite()).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number"))),
|
||
}
|
||
}
|
||
|
||
/// A text parameter, cut to `max` characters: absent or `null` → `None`; not text is an error.
|
||
fn text_param(p: &Value, key: &str, cmd: &str, max: usize) -> Result<Option<String>> {
|
||
match p.get(key) {
|
||
None | Some(Value::Null) => Ok(None),
|
||
Some(Value::String(t)) => Ok(Some(t.chars().take(max).collect())),
|
||
Some(_) => Err(bad(cmd, format!("`{key}` must be text"))),
|
||
}
|
||
}
|
||
|
||
/// A boolean parameter: absent or `null` → `None`; not a boolean is an error.
|
||
fn bool_param(p: &Value, key: &str, cmd: &str) -> Result<Option<bool>> {
|
||
match p.get(key) {
|
||
None | Some(Value::Null) => Ok(None),
|
||
Some(v) => v.as_bool().map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be true or false"))),
|
||
}
|
||
}
|
||
|
||
/// An enumeration parameter checked against `T`, as its JSON value.
|
||
fn enum_param<T: serde::de::DeserializeOwned + serde::Serialize>(p: &Value, key: &str, cmd: &str) -> Result<Option<Value>> {
|
||
match p.get(key) {
|
||
None | Some(Value::Null) => Ok(None),
|
||
Some(v) => {
|
||
let t: T = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("`{key}`: {e}")))?;
|
||
serde_json::to_value(t).map(Some).map_err(|e| bad(cmd, format!("`{key}`: {e}")))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A ruby enum parameter, checked against its type.
|
||
fn ruby_enum<T: serde::de::DeserializeOwned>(p: &Value, key: &str) -> Result<Option<Value>> {
|
||
let Some(v) = p.get(key) else { return Ok(None) };
|
||
serde_json::from_value::<T>(v.clone()).map_err(|e| bad("type.ruby", format!("{key}: {e}")))?;
|
||
Ok(Some(v.clone()))
|
||
}
|
||
|
||
/// A ruby number parameter clamped to `lo..=hi`, then divided by `div` (100 for percentages);
|
||
/// `null` gives `Value::Null` (automatic).
|
||
fn ruby_num(p: &Value, key: &str, lo: f64, hi: f64, div: f64) -> Result<Option<Value>> {
|
||
match p.get(key) {
|
||
None => Ok(None),
|
||
Some(Value::Null) => Ok(Some(Value::Null)),
|
||
Some(v) => {
|
||
let x = v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("type.ruby", format!("{key}: expected a finite number")))?;
|
||
Ok(Some(json!(x.clamp(lo, hi) / div)))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Is `flag` on in the character format at the start of the first formatting target?
|
||
fn char_flag_on(s: &Session, flag: impl Fn(&CharAttrs) -> Option<bool>) -> bool {
|
||
format_targets(s).first().is_some_and(|t| {
|
||
s.doc()
|
||
.ok()
|
||
.and_then(|d| d.doc.text_story(t.story, t.cell))
|
||
.is_some_and(|st| flag(&st.char_format_at(if t.range.is_empty() { t.range.start } else { t.range.start + 1 }).over) == Some(true))
|
||
})
|
||
}
|
||
|
||
fn kenten_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||
use designcraft_doc::cjk_settings::{AdornmentOverprint, KENTEN_MARK_MAX_CHARS, KentenAlignment, KentenKind, KentenPosition};
|
||
const CMD: &str = "type.kenten";
|
||
let mut body = serde_json::Map::new();
|
||
let mut set = |k: &str, v: Value| {
|
||
body.insert(k.to_string(), v);
|
||
};
|
||
// Validate every setting first, so a bad one changes nothing.
|
||
if let Some(v) = enum_param::<KentenKind>(p, "kind", CMD)? {
|
||
// A preset stores its mark as the character too, as IDML import does, so a custom
|
||
// character left from before isn't drawn in its place.
|
||
if let Some(mark) = serde_json::from_value::<KentenKind>(v.clone()).ok().and_then(KentenKind::mark) {
|
||
set("kentenCharacter", json!(mark));
|
||
}
|
||
set("kentenKind", v);
|
||
}
|
||
if let Some(c) = text_param(p, "character", CMD, KENTEN_MARK_MAX_CHARS)? {
|
||
set("kentenCharacter", json!(c.chars().filter(|c| !c.is_control()).collect::<String>()));
|
||
}
|
||
for (key, attr) in [("font", "kentenFont"), ("fontStyle", "kentenFontStyle"), ("fill", "kentenFill"), ("stroke", "kentenStroke")] {
|
||
if let Some(t) = text_param(p, key, CMD, NAME_MAX_CHARS)? {
|
||
set(attr, json!(t));
|
||
}
|
||
}
|
||
if let Some(v) = finite_param(p, "size", CMD)? {
|
||
set("kentenSize", json!(v.clamp(0.1, 1296.0)));
|
||
}
|
||
if let Some(v) = finite_param(p, "distance", CMD)? {
|
||
set("kentenDistance", json!(v.clamp(-1296.0, 1296.0)));
|
||
}
|
||
for (key, attr) in [("xScale", "kentenXScale"), ("yScale", "kentenYScale")] {
|
||
if let Some(v) = finite_param(p, key, CMD)? {
|
||
set(attr, json!(v.clamp(1.0, 1000.0) / 100.0));
|
||
}
|
||
}
|
||
for (key, attr) in [("fillTint", "kentenFillTint"), ("strokeTint", "kentenStrokeTint")] {
|
||
if let Some(v) = finite_param(p, key, CMD)? {
|
||
set(attr, json!(v.clamp(0.0, 100.0) / 100.0));
|
||
}
|
||
}
|
||
if let Some(v) = finite_param(p, "strokeWeight", CMD)? {
|
||
set("kentenStrokeWeight", json!(v.clamp(0.0, 1000.0)));
|
||
}
|
||
if let Some(v) = enum_param::<KentenPosition>(p, "position", CMD)? {
|
||
set("kentenPosition", v);
|
||
}
|
||
if let Some(v) = enum_param::<KentenAlignment>(p, "alignment", CMD)? {
|
||
set("kentenAlignment", v);
|
||
}
|
||
for (key, attr) in [("overprintFill", "kentenOverprintFill"), ("overprintStroke", "kentenOverprintStroke")] {
|
||
if let Some(v) = enum_param::<AdornmentOverprint>(p, key, CMD)? {
|
||
set(attr, v);
|
||
}
|
||
}
|
||
// `null` returns a setting to the style's (size: half the text size by default).
|
||
for (key, attr) in
|
||
[("size", "kentenSize"), ("fillTint", "kentenFillTint"), ("strokeTint", "kentenStrokeTint"), ("strokeWeight", "kentenStrokeWeight")]
|
||
{
|
||
if p.get(key).is_some_and(Value::is_null) {
|
||
set(attr, Value::Null);
|
||
}
|
||
}
|
||
let on = match bool_param(p, "on", CMD)? {
|
||
Some(v) => v,
|
||
None if !body.is_empty() => true,
|
||
None => !char_flag_on(s, |f| f.kenten),
|
||
};
|
||
if !on {
|
||
// Off keeps the settings for the next time kenten are switched on.
|
||
body = serde_json::Map::new();
|
||
}
|
||
body.insert("kenten".into(), json!(on));
|
||
format_chars(s, &Value::Object(body))
|
||
}
|
||
|
||
fn shatai_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "type.shatai";
|
||
let mut body = serde_json::Map::new();
|
||
if let Some(v) = finite_param(p, "magnification", CMD)? {
|
||
body.insert("shataiMagnification".into(), json!(v.clamp(0.0, 90.0)));
|
||
}
|
||
if let Some(v) = finite_param(p, "angle", CMD)? {
|
||
body.insert("shataiAngle".into(), json!(v.rem_euclid(180.0)));
|
||
}
|
||
if let Some(v) = bool_param(p, "adjustRotation", CMD)? {
|
||
body.insert("shataiAdjustRotation".into(), json!(v));
|
||
}
|
||
if let Some(v) = bool_param(p, "adjustTsume", CMD)? {
|
||
body.insert("shataiAdjustTsume".into(), json!(v));
|
||
}
|
||
if body.is_empty() {
|
||
return Err(bad(CMD, "nothing to set: magnification, angle, adjustRotation or adjustTsume"));
|
||
}
|
||
format_chars(s, &Value::Object(body))
|
||
}
|
||
|
||
fn auto_tcy_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "type.autoTateChuYoko";
|
||
let mut body = serde_json::Map::new();
|
||
if let Some(v) = finite_param(p, "digits", CMD)? {
|
||
let max = f64::from(designcraft_compose::shape::AUTO_TCY_MAX);
|
||
body.insert("autoTcy".into(), json!(v.round().clamp(0.0, max) as u32));
|
||
}
|
||
if let Some(v) = bool_param(p, "includeRoman", CMD)? {
|
||
body.insert("autoTcyIncludeRoman".into(), json!(v));
|
||
}
|
||
if body.is_empty() {
|
||
return Err(bad(CMD, "nothing to set: digits or includeRoman"));
|
||
}
|
||
format_paras(s, &Value::Object(body))
|
||
}
|
||
|
||
fn grid_settings_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||
use designcraft_doc::cjk::CharacterAlignment as R;
|
||
const CMD: &str = "type.gridSettings";
|
||
let mut body = serde_json::Map::new();
|
||
let first_only = bool_param(p, "firstLineOnly", CMD)?;
|
||
match text_param(p, "alignment", CMD, 32)?.as_deref() {
|
||
None => {
|
||
if let Some(f) = first_only {
|
||
body.insert("gridAlign".into(), json!(if f { "firstLineOnly" } else { "allLines" }));
|
||
}
|
||
}
|
||
Some("none") => {
|
||
body.insert("gridAlign".into(), json!("none"));
|
||
}
|
||
Some(name) => {
|
||
let reference = match name {
|
||
"romanBaseline" | "baseline" => R::Baseline,
|
||
"emTop" => R::EmTop,
|
||
"emCenter" => R::EmCenter,
|
||
"emBottom" => R::EmBottom,
|
||
"icfTop" => R::IcfTop,
|
||
"icfBottom" => R::IcfBottom,
|
||
other => return Err(bad(CMD, format!("unknown alignment `{other}`"))),
|
||
};
|
||
body.insert("gridAlign".into(), json!(if first_only == Some(true) { "firstLineOnly" } else { "allLines" }));
|
||
body.insert("gridReference".into(), json!(reference));
|
||
}
|
||
}
|
||
if let Some(v) = finite_param(p, "gyoudori", CMD)? {
|
||
let max = 100.0;
|
||
body.insert("gridGyoudori".into(), json!(v.round().clamp(0.0, max) as u32));
|
||
}
|
||
if let Some(v) = bool_param(p, "paragraphGyoudori", CMD)? {
|
||
body.insert("paragraphGyoudori".into(), json!(v));
|
||
}
|
||
if body.is_empty() {
|
||
return Err(bad(CMD, "nothing to set: alignment, firstLineOnly, gyoudori or paragraphGyoudori"));
|
||
}
|
||
format_paras(s, &Value::Object(body))
|
||
}
|
||
|
||
/// Switch a paragraph attribute of the selected paragraphs: `on`, else the opposite of the
|
||
/// first one's.
|
||
fn para_toggle(s: &mut Session, p: &Value, cmd: &str, attr: &str, get: impl Fn(&designcraft_doc::ParaProps) -> bool) -> Result<Value> {
|
||
let on = match bool_param(p, "on", cmd)? {
|
||
Some(v) => v,
|
||
None => !format_targets(s).first().is_some_and(|t| {
|
||
s.doc().ok().is_some_and(|d| {
|
||
d.doc
|
||
.text_story(t.story, t.cell)
|
||
.and_then(|st| st.paras.get(st.para_at(t.range.start)))
|
||
.is_some_and(|pf| get(&d.doc.styles.resolve_para(pf).0))
|
||
})
|
||
}),
|
||
};
|
||
let mut body = serde_json::Map::new();
|
||
body.insert(attr.to_string(), json!(on));
|
||
format_paras(s, &Value::Object(body))
|
||
}
|
||
|
||
fn ruby_text<'a>(p: &'a Value, key: &str) -> Result<Option<&'a str>> {
|
||
let Some(v) = p.get(key) else { return Ok(None) };
|
||
let t = v.as_str().ok_or_else(|| bad("type.ruby", format!("{key}: expected a string")))?;
|
||
if t.chars().count() > designcraft_doc::ruby::MAX_RUBY_CHARS {
|
||
return Err(bad("type.ruby", format!("{key}: longer than {} characters", designcraft_doc::ruby::MAX_RUBY_CHARS)));
|
||
}
|
||
Ok(Some(t))
|
||
}
|
||
|
||
fn ruby_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||
use designcraft_doc::cjk_settings::AdornmentOverprint;
|
||
use designcraft_doc::ruby::{RubyAlignment, RubyOverhang, RubyParentSpacing, RubyPosition, RubyType};
|
||
let mut body = serde_json::Map::new();
|
||
let mut put = |attr: &str, v: Option<Value>| {
|
||
if let Some(v) = v {
|
||
body.insert(attr.into(), v);
|
||
}
|
||
};
|
||
put("ruby", ruby_text(p, "text")?.map(|t| json!(t)));
|
||
put("rubyType", ruby_enum::<RubyType>(p, "type")?);
|
||
put("rubyAlignment", ruby_enum::<RubyAlignment>(p, "alignment")?);
|
||
put("rubyPosition", ruby_enum::<RubyPosition>(p, "position")?);
|
||
put("rubyOverhangAmount", ruby_enum::<RubyOverhang>(p, "overhang")?);
|
||
put("rubyParentSpacing", ruby_enum::<RubyParentSpacing>(p, "parentSpacing")?);
|
||
put("rubyOverprintFill", ruby_enum::<AdornmentOverprint>(p, "overprintFill")?);
|
||
put("rubyOverprintStroke", ruby_enum::<AdornmentOverprint>(p, "overprintStroke")?);
|
||
put("rubyXOffset", ruby_num(p, "xOffset", -1000.0, 1000.0, 1.0)?.filter(|v| !v.is_null()));
|
||
put("rubyYOffset", ruby_num(p, "yOffset", -1000.0, 1000.0, 1.0)?.filter(|v| !v.is_null()));
|
||
put("rubyFontSize", ruby_num(p, "size", 0.1, 1296.0, 1.0)?);
|
||
put("rubyXScale", ruby_num(p, "xScale", 1.0, 1000.0, 100.0)?.filter(|v| !v.is_null()));
|
||
put("rubyYScale", ruby_num(p, "yScale", 1.0, 1000.0, 100.0)?.filter(|v| !v.is_null()));
|
||
put("rubyScalingMin", ruby_num(p, "scalingPercent", 10.0, 100.0, 100.0)?.filter(|v| !v.is_null()));
|
||
put("rubyFillTint", ruby_num(p, "fillTint", 0.0, 100.0, 100.0)?);
|
||
put("rubyStrokeTint", ruby_num(p, "strokeTint", 0.0, 100.0, 100.0)?);
|
||
put("rubyStrokeWeight", ruby_num(p, "strokeWeight", 0.0, 800.0, 1.0)?);
|
||
put(
|
||
"rubyAutoTcyDigits",
|
||
ruby_num(p, "autoTcyDigits", 0.0, 9.0, 1.0)?.filter(|v| !v.is_null()).and_then(|v| v.as_f64()).map(|v| json!(v.round() as u32)),
|
||
);
|
||
for (key, attr) in [
|
||
("openTypePro", "rubyOpenTypePro"),
|
||
("autoTcyIncludeRoman", "rubyAutoTcyIncludeRoman"),
|
||
("autoTcyAutoScale", "rubyAutoTcyAutoScale"),
|
||
("autoAlign", "rubyAutoAlign"),
|
||
("autoScaling", "rubyAutoScaling"),
|
||
] {
|
||
if let Some(v) = p.get(key) {
|
||
let b = v.as_bool().ok_or_else(|| bad("type.ruby", format!("{key}: expected true or false")))?;
|
||
put(attr, Some(json!(b)));
|
||
}
|
||
}
|
||
put("rubyFont", ruby_text(p, "font")?.map(|t| json!(t)));
|
||
put("rubyFontStyle", ruby_text(p, "fontStyle")?.map(|t| json!(t)));
|
||
for (key, attr) in [("fill", "rubyFill"), ("stroke", "rubyStroke")] {
|
||
if let Some(name) = ruby_text(p, key)? {
|
||
let known = name.is_empty() || name == designcraft_doc::color::swatch::NONE || s.doc()?.doc.swatch(name).is_some();
|
||
if !known {
|
||
return Err(bad("type.ruby", format!("{key}: no swatch `{name}`")));
|
||
}
|
||
put(attr, Some(json!(name)));
|
||
}
|
||
}
|
||
format_chars(s, &Value::Object(body))
|
||
}
|
||
|
||
/// A character fill must name a swatch of the document.
|
||
pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &CharAttrs, command_id: &str) -> Result<()> {
|
||
match &attrs.fill {
|
||
Some(fill) => super::object::known_swatch(document, fill, command_id, "fill"),
|
||
None => Ok(()),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
|
||
let mut a = CharAttrs::default();
|
||
if let Some(o) = attrs.as_object() {
|
||
for (k, v) in o {
|
||
a.set_json(k, v).map_err(|e| bad("type.char", e))?;
|
||
}
|
||
}
|
||
validate_char_fill(&s.doc()?.doc, &a, "type.char")?;
|
||
let cleared: Vec<String> = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default();
|
||
let targets = format_targets(s);
|
||
// A caret in text: the format is for the next typing.
|
||
if let [t] = targets.as_slice()
|
||
&& t.range.is_empty()
|
||
&& let Some(at) = s.doc()?.selection.text
|
||
&& s.doc()?.doc.text_story(t.story, t.cell).is_some_and(|st| !st.is_empty())
|
||
{
|
||
let doc = s.doc()?.uid;
|
||
let mut f = typing_format(s).cloned().unwrap_or(TypingFormat { doc, at, attrs: CharAttrs::default(), cleared: vec![] });
|
||
f.attrs.merge(&a);
|
||
f.cleared.retain(|k| attrs.get(k).is_none_or(|v| v.is_null()) && !cleared.contains(k));
|
||
f.cleared.extend(cleared);
|
||
s.typing_format = Some(f);
|
||
return ok();
|
||
}
|
||
s.edit(|d, _| {
|
||
apply_char_attrs(d, &targets, &a, &cleared);
|
||
ok()
|
||
})
|
||
}
|
||
|
||
/// Character attributes `a` (and the overrides `cleared` removed) over each target's text.
|
||
pub(crate) fn apply_char_attrs(d: &mut designcraft_doc::Document, targets: &[Target], a: &CharAttrs, cleared: &[String]) {
|
||
for t in targets {
|
||
let r = &t.range;
|
||
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
|
||
if r.is_empty() {
|
||
// An empty story: its one (empty) run is the format typing takes.
|
||
if st.is_empty()
|
||
&& let Some(run) = st.chars.first_mut()
|
||
{
|
||
run.format.over.merge(a);
|
||
st.rev = st.rev.wrapping_add(1);
|
||
}
|
||
continue;
|
||
}
|
||
st.format_chars(r.clone(), |f| {
|
||
f.over.merge(a);
|
||
for k in cleared {
|
||
let _ = f.over.set_json(k, &Value::Null);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result<Value> {
|
||
let empty = serde_json::Map::new();
|
||
let obj = attrs.as_object().unwrap_or(&empty);
|
||
// A rule object changes only the rule fields it names, so each paragraph gets its own attrs
|
||
// over its resolved rule; this pass validates them.
|
||
let mut a = ParaAttrs::default();
|
||
for (k, v) in obj {
|
||
a.set_json_over(k, v, &designcraft_doc::ParaProps::default()).map_err(|e| bad("type.para", e))?;
|
||
}
|
||
super::style::check_grep_styles("type.para", &a)?;
|
||
// `null` removes the override (back to the style's value).
|
||
let cleared: Vec<String> = obj.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect();
|
||
let targets = format_targets(s);
|
||
s.edit(|d, _| {
|
||
let styles = d.styles.clone();
|
||
for t in &targets {
|
||
if let Some(st) = d.text_story_mut(t.story, t.cell) {
|
||
st.format_paras(t.range.clone(), |p| {
|
||
let (current, _) = styles.resolve_para(p);
|
||
let mut a = a.clone();
|
||
for (k, v) in obj {
|
||
let _ = a.set_json_over(k, v, ¤t);
|
||
}
|
||
p.para.merge(&a);
|
||
for k in &cleared {
|
||
let _ = p.para.set_json(k, &Value::Null);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
ok()
|
||
})
|
||
}
|
||
|
||
/// Alt+Left/Right in text: kerning at a caret, tracking of a selection, by the Kerning/Tracking
|
||
/// increment.
|
||
fn kern_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||
use designcraft_doc::Kerning;
|
||
let inc = s.doc()?.doc.settings.kerning_increment;
|
||
let inc = if inc.is_finite() { inc.clamp(1.0, 1000.0) } else { 20.0 };
|
||
let amount = match p.get("amount").and_then(Value::as_f64) {
|
||
Some(a) => a,
|
||
None => p.get("steps").and_then(Value::as_f64).unwrap_or(1.0) * inc,
|
||
};
|
||
if !amount.is_finite() {
|
||
return Err(bad("type.kernStep", "`amount` must be a number"));
|
||
}
|
||
let amount = amount.clamp(-11000.0, 11000.0);
|
||
// Kerning and tracking range, in 1/1000 em.
|
||
let clamp = |v: f64| v.clamp(-1000.0, 10000.0);
|
||
let targets = format_targets(s);
|
||
s.edit(|d, _| {
|
||
let styles = d.styles.clone();
|
||
let mut out = json!({});
|
||
for t in &targets {
|
||
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
|
||
if t.range.is_empty() {
|
||
// Manual kerning sits on the character before the caret (it applies after it).
|
||
let pos = t.range.start;
|
||
let Some(c) = st.text.get(..pos).and_then(|before| before.chars().next_back()) else { continue };
|
||
let at = pos - c.len_utf8();
|
||
let para = st.paras.get(st.para_at(at)).cloned().unwrap_or_default();
|
||
let current = styles.resolve_char(&styles.resolve_para(¶).1, st.format_after(at)).kerning;
|
||
// The font's metrics or optical value isn't known here: those start from 0.
|
||
let v = clamp(if let Kerning::Manual(v) = current { v } else { 0.0 } + amount);
|
||
st.format_chars(at..pos, |f| f.over.kerning = Some(Kerning::Manual(v)));
|
||
out = json!({"kerning": v});
|
||
} else {
|
||
for (i, pr) in st.para_ranges().into_iter().enumerate() {
|
||
let r = pr.start.max(t.range.start)..pr.end.min(t.range.end);
|
||
let Some(para) = st.paras.get(i).cloned() else { break };
|
||
if r.is_empty() {
|
||
continue;
|
||
}
|
||
let pc = styles.resolve_para(¶).1;
|
||
st.format_chars(r, |f| {
|
||
let v = clamp(styles.resolve_char(&pc, f).tracking + amount);
|
||
f.over.tracking = Some(v);
|
||
out = json!({"tracking": v});
|
||
});
|
||
}
|
||
}
|
||
}
|
||
Ok(out)
|
||
})
|
||
}
|
||
|
||
fn step_size(s: &mut Session, delta: f64) -> Result<Value> {
|
||
let cur = selection_attrs(s, &json!({}))?;
|
||
let size = cur["chars"]["size"].as_f64().unwrap_or(12.0);
|
||
format_chars(s, &json!({"size": (size + delta).clamp(0.1, 1296.0)}))
|
||
}
|
||
|
||
fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||
use designcraft_doc::{CharFormat, CharProps, ParaProps};
|
||
let para = match p.get("kind").and_then(Value::as_str).unwrap_or("paragraph") {
|
||
"paragraph" => true,
|
||
"character" => false,
|
||
k => return Err(bad("style.breakLink", format!("unknown kind `{k}`"))),
|
||
};
|
||
let targets = format_targets(s);
|
||
s.edit(|d, _| {
|
||
let styles = d.styles.clone();
|
||
for t in &targets {
|
||
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
|
||
let r = t.range.clone();
|
||
if para {
|
||
st.format_paras(r, |pf| {
|
||
let (pp, pc) = styles.resolve_para(pf);
|
||
pf.style = designcraft_doc::styles::NO_PARA_STYLE.into();
|
||
pf.para = ParaProps::default().delta(&pp);
|
||
pf.chars = CharProps::default().delta(&pc);
|
||
});
|
||
} else {
|
||
let ranges = st.para_ranges();
|
||
for (pi, pr) in ranges.into_iter().enumerate() {
|
||
let (a, b) = (pr.start.max(r.start), pr.end.min(r.end));
|
||
if a >= b {
|
||
continue;
|
||
}
|
||
let (_, pc) = styles.resolve_para(&st.paras[pi]);
|
||
st.format_chars(a..b, |f| {
|
||
let cp = styles.resolve_char(&pc, f);
|
||
*f = CharFormat { style: designcraft_doc::story::NO_CHAR_STYLE.into(), over: pc.delta(&cp) };
|
||
});
|
||
}
|
||
}
|
||
}
|
||
ok()
|
||
})
|
||
}
|
||
|
||
fn open_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||
use designcraft_doc::otf;
|
||
let cur = selection_attrs(s, &json!({}))?;
|
||
let mut list: Vec<String> =
|
||
cur["chars"]["otfFeatures"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||
if let Some(tag) = p.get("feature").and_then(Value::as_str) {
|
||
if tag.is_empty() || tag.len() > 4 || !tag.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||
return Err(bad("type.openType", format!("bad feature tag `{tag}`")));
|
||
}
|
||
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!otf::is_on(&list, tag));
|
||
otf::set(&mut list, tag, on);
|
||
}
|
||
if let Some(f) = p.get("figures").and_then(Value::as_str)
|
||
&& !otf::set_figures(&mut list, f)
|
||
{
|
||
return Err(bad("type.openType", format!("unknown figure style `{f}`")));
|
||
}
|
||
if let Some(sets) = p.get("stylisticSets").and_then(Value::as_array) {
|
||
let mask = sets.iter().filter_map(Value::as_u64).filter(|n| (1..=20).contains(n)).fold(0u32, |m, n| m | 1 << (n - 1));
|
||
otf::set_stylistic_sets(&mut list, mask);
|
||
}
|
||
format_chars(s, &json!({"otfFeatures": list}))?;
|
||
Ok(json!({"features": list, "figures": otf::figures(&list)}))
|
||
}
|
||
|
||
/// How much of the selected text `type.selectionAttrs` reports.
|
||
const SELECTED_TEXT_CHARS: usize = 60;
|
||
|
||
fn selected_text_preview(chars: impl Iterator<Item = char>) -> String {
|
||
chars
|
||
.map(|c| if c.is_control() || c == designcraft_doc::OBJECT_MARK { ' ' } else { c })
|
||
.skip_while(|c| c.is_whitespace())
|
||
.take(SELECTED_TEXT_CHARS)
|
||
.collect()
|
||
}
|
||
|
||
fn selection_attrs(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let st = s.doc()?;
|
||
let targets = format_targets(s);
|
||
let Some(Target { story: sid, cell, range: r }) = targets.first().cloned() else { return Ok(Value::Null) };
|
||
let story = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?;
|
||
let pi = story.para_at(r.start);
|
||
let pf = &story.paras[pi];
|
||
let (pp, base) = st.doc.styles.resolve_para(pf);
|
||
let mut cf = if r.is_empty() { story.char_format_at(r.start) } else { story.format_after(r.start) }.clone();
|
||
if let Some(f) = typing_format(s) {
|
||
apply_typing_format(&mut cf, f);
|
||
}
|
||
let cp = st.doc.styles.resolve_char(&base, &cf);
|
||
// The selected text (its start, on one line), for previews such as the font menu's.
|
||
let selected = selected_text_preview(story.text.get(r.start..r.end).unwrap_or_default().chars());
|
||
let mut out = json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp,
|
||
"paraOverrides": pf.para.count(), "charOverrides": cf.over.count(), "selectedText": selected.trim()});
|
||
if bool_or(p, "mixed", false) {
|
||
out["paraMixed"] = json!(mixed_para_attrs(&st.doc, &targets));
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// The paragraph attributes whose resolved values differ across the paragraphs of `targets`.
|
||
fn mixed_para_attrs(doc: &designcraft_doc::Document, targets: &[Target]) -> Vec<String> {
|
||
let mut first: Option<serde_json::Map<String, Value>> = None;
|
||
let mut mixed = std::collections::BTreeSet::new();
|
||
for t in targets {
|
||
let Some(story) = doc.text_story(t.story, t.cell) else { continue };
|
||
let a = story.para_at(t.range.start);
|
||
let b = story.para_at(t.range.end.max(t.range.start));
|
||
for pf in story.paras.get(a..=b).unwrap_or(&[]) {
|
||
let Ok(Value::Object(v)) = serde_json::to_value(doc.styles.resolve_para(pf).0) else { continue };
|
||
match &first {
|
||
None => first = Some(v),
|
||
Some(f) => mixed.extend(v.into_iter().filter(|(k, x)| f.get(k) != Some(x)).map(|(k, _)| k)),
|
||
}
|
||
}
|
||
}
|
||
mixed.into_iter().collect()
|
||
}
|
||
|
||
/// `cf` with the typing format chosen at the active document's caret, if any.
|
||
pub(crate) fn with_typing_format(s: &Session, cf: &mut designcraft_doc::CharFormat) {
|
||
if let Some(f) = typing_format(s) {
|
||
apply_typing_format(cf, f);
|
||
}
|
||
}
|
||
|
||
fn apply_typing_format(cf: &mut designcraft_doc::CharFormat, f: &TypingFormat) {
|
||
cf.over.merge(&f.attrs);
|
||
for k in &f.cleared {
|
||
let _ = cf.over.set_json(k, &Value::Null);
|
||
}
|
||
}
|
||
|
||
/// Bold (`bold`) or italic from the font family (style linking), per run of the selection: on or
|
||
/// off as asked, else the opposite of the first selected character.
|
||
fn link_style(s: &mut Session, p: &Value, bold: bool) -> Result<Value> {
|
||
let id = if bold { "type.bold" } else { "type.italic" };
|
||
let cur = selection_attrs(s, &json!({}))?;
|
||
let family = cur["chars"]["fontFamily"].as_str().unwrap_or_default().to_string();
|
||
let style = cur["chars"]["fontStyle"].as_str().unwrap_or_default().to_string();
|
||
// The document's own fonts count too.
|
||
let db = designcraft_fonts::FontDb::global().scoped(s.doc()?.doc.font_scope);
|
||
let (weight, italic) = db.traits_of(&family, &style);
|
||
let on = p.get("on").and_then(Value::as_bool).unwrap_or(if bold { weight < 650.0 } else { !italic });
|
||
let (want_bold, want_italic) = if bold { (Some(on), None) } else { (None, Some(on)) };
|
||
let targets = format_targets(s);
|
||
// A caret: the linked style of the format there, for the next typing.
|
||
if let [t] = targets.as_slice()
|
||
&& t.range.is_empty()
|
||
{
|
||
let linked = db.linked_style(&family, &style, want_bold, want_italic).ok_or_else(|| bad(id, format!("{family} has no style for that")))?;
|
||
format_chars(s, &json!({"fontStyle": linked}))?;
|
||
return Ok(json!({"on": on, "fontStyle": linked}));
|
||
}
|
||
let mut changed = 0;
|
||
let r = s.edit(|d, _| {
|
||
let styles = d.styles.clone();
|
||
for t in &targets {
|
||
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
|
||
for (pi, pr) in st.para_ranges().into_iter().enumerate() {
|
||
let (a, b) = (pr.start.max(t.range.start), pr.end.min(t.range.end));
|
||
if a >= b {
|
||
continue;
|
||
}
|
||
let Some(pf) = st.paras.get(pi) else { continue };
|
||
let (_, pc) = styles.resolve_para(pf);
|
||
st.format_chars(a..b, |f| {
|
||
let cp = styles.resolve_char(&pc, f);
|
||
if let Some(linked) = db.linked_style(&cp.font_family, &cp.font_style, want_bold, want_italic)
|
||
&& linked != cp.font_style
|
||
{
|
||
f.over.font_style = Some(linked);
|
||
changed += 1;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
// No undo step for nothing.
|
||
if changed == 0 {
|
||
return Err(bad(id, "nothing to change"));
|
||
}
|
||
ok()
|
||
});
|
||
r?;
|
||
Ok(json!({"on": on, "changed": changed}))
|
||
}
|
||
|
||
/// Our own filler text (not Adobe's).
|
||
pub const PLACEHOLDER: &str = "Ovid ellum quisque arcet velut tempora sint, ne veriora pareant laudemque fieri. Lorem novum strata ponet amicos, \
|
||
et tamen uti ferrum caelo tempus mollit. Quisque sinat oppida retro, nec tenuere longas animi semper vias. \
|
||
Sic erat, ut nostris ceperunt pectora verbis, mitescunt flamma solidos campos venti. Moventur iam nubes, aequora pontus, \
|
||
dum sidera fulgent tacito per inane meatu. Arbore sub magna ludunt pueri, linquunt aurea litora fluctus. \
|
||
Iamque tibi rursus ponet nova carmina vates, et longas umbras quaerit sub tegmine fagi. Omnia mutantur, nihil interit.";
|
||
|
||
fn fill_placeholder(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let st = s.doc()?;
|
||
let frame = id_param(p, "frame").or_else(|| st.selection.items.first().copied()).or_else(|| st.selection.text.and_then(|t| t.frame));
|
||
let sid = match frame.and_then(|f| st.doc.item(f)).and_then(|i| i.text_frame()) {
|
||
Some(t) => t.story,
|
||
None => {
|
||
if let Some(f) = frame {
|
||
s.execute("object.content", &json!({"ids": [f.0], "type": "text"}))?;
|
||
}
|
||
story_of(s, p).ok_or_else(|| bad("type.fillWithPlaceholder", "select a text frame"))?
|
||
}
|
||
};
|
||
// Fill until overset: repeat the filler sentences.
|
||
let mut text = String::new();
|
||
let sentences: Vec<&str> = PLACEHOLDER.split_inclusive(". ").collect();
|
||
let doc = s.doc()?.doc.clone();
|
||
let mut d2 = (*doc).clone();
|
||
for i in 0..400 {
|
||
text.push_str(sentences[i % sentences.len()]);
|
||
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);
|
||
text.truncate(keep);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
s.edit(|d, _| {
|
||
d.set_story_text(sid, text.trim_end())?;
|
||
ok()
|
||
})
|
||
}
|
||
|
||
pub(crate) fn format_targets_pub(s: &Session) -> Vec<Target> {
|
||
format_targets(s)
|
||
}
|
||
|
||
/// The case-changed text of `src`, char by char (a char may map to several, e.g. ß → SS).
|
||
pub(crate) fn case_map(src: &str, case: &str) -> Vec<String> {
|
||
let mut out = Vec::with_capacity(src.len());
|
||
let mut prev = ' ';
|
||
let mut sentence_start = true;
|
||
for c in src.chars() {
|
||
let word_start = !(prev.is_alphanumeric() || matches!(prev, '\'' | '\u{2019}'));
|
||
let up = |c: char| c.to_uppercase().collect::<String>();
|
||
let low = |c: char| c.to_lowercase().collect::<String>();
|
||
let m = match case {
|
||
"upper" => up(c),
|
||
"lower" => low(c),
|
||
"title" => {
|
||
if word_start {
|
||
up(c)
|
||
} else {
|
||
low(c)
|
||
}
|
||
}
|
||
_ => {
|
||
if sentence_start && c.is_alphanumeric() {
|
||
up(c)
|
||
} else {
|
||
low(c)
|
||
}
|
||
}
|
||
};
|
||
if c.is_alphanumeric() {
|
||
sentence_start = false;
|
||
} else if matches!(c, '.' | '!' | '?' | '\n' | '\u{2028}') {
|
||
sentence_start = true;
|
||
}
|
||
out.push(m);
|
||
prev = c;
|
||
}
|
||
out
|
||
}
|
||
|
||
fn change_case(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let case = str_param(p, "case").unwrap_or("upper").to_string();
|
||
if !matches!(case.as_str(), "upper" | "lower" | "title" | "sentence") {
|
||
return Err(bad("type.changeCase", format!("unknown case `{case}`")));
|
||
}
|
||
let targets = format_targets(s);
|
||
s.edit(|d, sel| {
|
||
for t in &targets {
|
||
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
|
||
let r = t.range.start.min(st.len())..t.range.end.min(st.len());
|
||
let mapped = case_map(st.slice(r.clone()), &case);
|
||
// Replace run by run (from the end) so every run keeps its formatting.
|
||
let segs: Vec<std::ops::Range<usize>> =
|
||
st.runs().map(|(rr, _)| rr.start.max(r.start)..rr.end.min(r.end)).filter(|x| x.start < x.end).collect();
|
||
let starts: Vec<usize> = st.slice(r.clone()).char_indices().map(|(i, _)| r.start + i).collect();
|
||
let mut delta: isize = 0;
|
||
for seg in segs.iter().rev() {
|
||
let old = st.slice(seg.clone()).to_string();
|
||
let new: String = starts.iter().zip(&mapped).filter(|(b, _)| seg.contains(b)).map(|(_, m)| m.as_str()).collect();
|
||
if new != old {
|
||
delta += new.len() as isize - old.len() as isize;
|
||
st.replace(seg.clone(), &new);
|
||
}
|
||
}
|
||
if let Some(ts) = sel.text.as_mut().filter(|ts| ts.story == t.story && ts.cell == t.cell && !ts.is_caret()) {
|
||
let end = (r.end as isize + delta).max(r.start as isize) as usize;
|
||
if ts.anchor <= ts.focus {
|
||
ts.focus = end;
|
||
} else {
|
||
ts.anchor = end;
|
||
}
|
||
}
|
||
}
|
||
Ok(Value::Null)
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod open_type_tests {
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
|
||
#[test]
|
||
fn text_selection_stays_on_utf8_boundaries() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "é漢🙂x"})).unwrap();
|
||
let sid = r["story"].clone();
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 1, "focus": 4})).unwrap();
|
||
let t = s.doc().unwrap().selection.text.unwrap();
|
||
assert_eq!((t.anchor, t.focus), (0, 2));
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 4})).unwrap();
|
||
s.execute("text.delete", &json!({"forward": true})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.story(t.story).unwrap().text, "é🙂x");
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 2})).unwrap();
|
||
s.execute("story.setText", &json!({"story": sid, "text": "漢字"})).unwrap();
|
||
let t = s.doc().unwrap().selection.text.unwrap();
|
||
assert_eq!((t.anchor, t.focus), (0, 0));
|
||
s.execute("text.move", &json!({"dir": "right"})).unwrap();
|
||
assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, 3);
|
||
}
|
||
|
||
#[test]
|
||
fn replacing_a_range_stays_on_utf8_boundaries() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "é漢🙂x"})).unwrap();
|
||
let sid = r["story"].clone();
|
||
// Offsets inside 漢 (2..5) and 🙂 (5..9) snap back to the character starts: 漢 is replaced.
|
||
s.execute("story.replaceRange", &json!({"story": sid, "start": 3, "end": 6, "text": "字"})).unwrap();
|
||
let story = designcraft_doc::StoryId(sid.as_u64().unwrap());
|
||
assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "é字🙂x");
|
||
// A caret before 🙂 (byte 5) would land inside it once é (2 bytes) is gone.
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap();
|
||
s.execute("story.replaceRange", &json!({"story": sid, "start": 0, "end": 2, "text": ""})).unwrap();
|
||
let st = s.doc().unwrap();
|
||
let t = st.selection.text.unwrap();
|
||
let text = &st.doc.story(t.story).unwrap().text;
|
||
assert_eq!(text, "字🙂x");
|
||
assert!(text.is_char_boundary(t.anchor) && text.is_char_boundary(t.focus), "caret {t:?} in {text:?}");
|
||
s.execute("text.move", &json!({"dir": "right"})).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn selected_text_preview_stops_at_its_character_budget() {
|
||
let read = std::cell::Cell::new(0usize);
|
||
let prefix = ['\t', '\u{2003}', designcraft_doc::OBJECT_MARK];
|
||
let chars = prefix.into_iter().chain(std::iter::repeat_n('字', 100_000)).inspect(|_| read.set(read.get() + 1));
|
||
let preview = super::selected_text_preview(chars);
|
||
assert_eq!(preview, "字".repeat(super::SELECTED_TEXT_CHARS));
|
||
assert_eq!(read.get(), prefix.len() + super::SELECTED_TEXT_CHARS, "the rest of a large selection is never copied or scanned");
|
||
assert_eq!(super::selected_text_preview("A\nB".chars()), "A B");
|
||
}
|
||
|
||
#[test]
|
||
fn selection_attrs_report_the_selected_text() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let r = s
|
||
.execute(
|
||
"frame.create",
|
||
&json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Spring issue
|
||
What's new in typography this season, and why it matters to every designer"}),
|
||
)
|
||
.unwrap();
|
||
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 13})).unwrap();
|
||
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "Spring issue", "on one line, trimmed");
|
||
s.execute("text.select", &json!({"story": r["story"], "anchor": 13, "focus": 200})).unwrap();
|
||
let long = s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"].as_str().unwrap().to_string();
|
||
assert!(long.starts_with("What's new") && (55..=60).contains(&long.chars().count()), "the line break skipped, capped: {long}");
|
||
s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap();
|
||
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "", "a caret selects nothing");
|
||
}
|
||
|
||
#[test]
|
||
fn open_type_toggles_reach_the_text() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Office 1/2 0"})).unwrap();
|
||
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 12})).unwrap();
|
||
let r = s.execute("type.openType", &json!({"feature": "dlig"})).unwrap();
|
||
assert_eq!(r["features"], json!(["dlig"]));
|
||
let r = s.execute("type.openType", &json!({"feature": "dlig"})).unwrap();
|
||
assert_eq!(r["features"], json!([]), "toggles off");
|
||
let r = s.execute("type.openType", &json!({"figures": "proportionalOldstyle", "stylisticSets": [1, 2]})).unwrap();
|
||
assert_eq!(r["figures"], "proportionalOldstyle");
|
||
let a = s.execute("type.selectionAttrs", &json!({})).unwrap();
|
||
let f: Vec<&str> = a["chars"]["otfFeatures"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect();
|
||
assert!(f.contains(&"onum") && f.contains(&"ss02"), "{f:?}");
|
||
assert!(s.execute("type.openType", &json!({"feature": "bad tag!"})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn break_link_to_paragraph_and_character_styles() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
s.execute("style.paragraph.create", &json!({"name": "Head", "para": {"spaceBefore": 6}, "chars": {"size": 24}})).unwrap();
|
||
s.execute("style.character.create", &json!({"name": "Em", "chars": {"fontStyle": "Italic"}})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Title here"})).unwrap();
|
||
let sid = r["story"].as_u64().unwrap();
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 10})).unwrap();
|
||
s.execute("style.paragraph.apply", &json!({"name": "Head"})).unwrap();
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap();
|
||
s.execute("style.character.apply", &json!({"name": "Em"})).unwrap();
|
||
let before = s.execute("type.selectionAttrs", &json!({})).unwrap();
|
||
s.execute("style.breakLink", &json!({"kind": "character"})).unwrap();
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 10})).unwrap();
|
||
s.execute("style.breakLink", &json!({"kind": "paragraph"})).unwrap();
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap();
|
||
let after = s.execute("type.selectionAttrs", &json!({})).unwrap();
|
||
assert_eq!(after["paragraphStyle"], "[No Paragraph Style]");
|
||
assert_eq!(after["characterStyle"], "[None]");
|
||
assert_eq!(after["chars"], before["chars"], "same look");
|
||
assert_eq!(after["para"], before["para"]);
|
||
}
|
||
|
||
/// Old-style figures shape to other glyphs than the default lining ones (Source Serif 4).
|
||
#[test]
|
||
fn oldstyle_figures_change_the_glyphs() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "2024"})).unwrap();
|
||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||
let gids = |s: &Session| {
|
||
let d = &s.doc().unwrap().doc;
|
||
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
|
||
cs.frames[0].lines[0].glyphs.iter().map(|g| g.gid).collect::<Vec<_>>()
|
||
};
|
||
let lining = gids(&s);
|
||
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 4})).unwrap();
|
||
s.execute("type.openType", &json!({"figures": "proportionalOldstyle"})).unwrap();
|
||
assert_ne!(gids(&s), lining);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod nested_style_tests {
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
|
||
#[test]
|
||
fn nested_and_grep_styles_colour_the_text() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
s.execute("style.character.create", &json!({"name": "Lead", "chars": {"fill": "C=100 M=0 Y=0 K=0"}})).unwrap();
|
||
s.execute("style.character.create", &json!({"name": "Num", "chars": {"fill": "C=0 M=100 Y=0 K=0"}})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Opening words then 2026 here"})).unwrap();
|
||
let sid = r["story"].as_u64().unwrap();
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap();
|
||
s.execute(
|
||
"type.para",
|
||
&json!({"attrs": {
|
||
"nestedStyles": [{"style": "Lead", "through": true, "count": 2, "until": {"kind": "words"}}],
|
||
"grepStyles": [{"style": "Num", "pattern": "\\d+"}]
|
||
}}),
|
||
)
|
||
.unwrap();
|
||
let d = s.doc().unwrap().doc.clone();
|
||
let cs = designcraft_compose::compose_story(&d, designcraft_doc::StoryId(sid), &Default::default());
|
||
let text = &d.story(designcraft_doc::StoryId(sid)).unwrap().text;
|
||
let fill_at = |byte: usize| {
|
||
let g = cs.frames[0].lines.iter().flat_map(|l| &l.glyphs).find(|g| g.byte == byte).unwrap();
|
||
cs.styles[g.style as usize].fill.clone()
|
||
};
|
||
assert_eq!(fill_at(0), "C=100 M=0 Y=0 K=0", "nested: first two words");
|
||
assert_eq!(fill_at(text.find("words").unwrap()), "C=100 M=0 Y=0 K=0");
|
||
assert_eq!(fill_at(text.find("then").unwrap()), "[Black]");
|
||
assert_eq!(fill_at(text.find("2026").unwrap()), "C=0 M=100 Y=0 K=0", "GREP: digits");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod multi_click_tests {
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::Session;
|
||
|
||
#[test]
|
||
fn moving_the_caret_drops_the_pending_typing_format() {
|
||
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": "Hello world"})).unwrap();
|
||
let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());
|
||
let size_at = |s: &Session, b: usize| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().char_format_at(b).over.size;
|
||
let pending = |s: &mut Session| {
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap();
|
||
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
|
||
};
|
||
// Away and back by arrow keys, by a click, by selecting and by double-clicking: typing at the
|
||
// same caret is in the text's own format.
|
||
let moves: Vec<Box<dyn Fn(&mut Session)>> = vec![
|
||
Box::new(|s| {
|
||
s.execute("text.move", &json!({"dir": "right"})).unwrap();
|
||
s.execute("text.move", &json!({"dir": "left"})).unwrap();
|
||
}),
|
||
Box::new(|s| {
|
||
s.execute("text.placeCaret", &json!({"frame": fid, "point": [80, 80]})).unwrap();
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap();
|
||
}),
|
||
Box::new(|s| {
|
||
s.execute("text.selectAt", &json!({"frame": fid, "point": [80, 80], "unit": "story"})).unwrap();
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap();
|
||
}),
|
||
];
|
||
for m in &moves {
|
||
pending(&mut s);
|
||
m(&mut s);
|
||
s.execute("text.insert", &json!({"text": "x"})).unwrap();
|
||
assert_eq!(size_at(&s, 6), None);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
}
|
||
// Without a move it applies.
|
||
pending(&mut s);
|
||
s.execute("text.insert", &json!({"text": "x"})).unwrap();
|
||
assert_eq!(size_at(&s, 6), Some(30.0));
|
||
}
|
||
|
||
#[test]
|
||
fn word_selection_keeps_grapheme_clusters_whole() {
|
||
use super::word_or_spaces_at as word;
|
||
// Decomposed "café": the combining acute belongs to the word, wherever the click lands.
|
||
let s = "un cafe\u{301} noir";
|
||
let cafe = (3, 3 + "cafe\u{301}".len());
|
||
assert_eq!(word(s, 4), cafe);
|
||
assert_eq!(word(s, 7), cafe, "on the combining mark");
|
||
// Devanagari with a virama (U+094D) and a dependent vowel sign.
|
||
let s = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947} x";
|
||
let w = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947}".len();
|
||
assert_eq!(word(s, 0), (0, w));
|
||
assert_eq!(word(s, 9), (0, w), "on the virama");
|
||
// An emoji ZWJ sequence and a variation selector stay one character.
|
||
let s = "a \u{1F469}\u{200D}\u{1F4BB} b \u{263A}\u{FE0F}";
|
||
let e = (2, 2 + "\u{1F469}\u{200D}\u{1F4BB}".len());
|
||
assert_eq!(word(s, 2), e);
|
||
assert_eq!(word(s, 6), e, "on the joiner");
|
||
assert_eq!(word(s, s.len() - 1), (s.len() - 6, s.len()), "at the end: the last character whole");
|
||
// Empty paragraphs select nothing.
|
||
assert_eq!(word("a\n\nb", 2), (2, 2));
|
||
}
|
||
|
||
#[test]
|
||
fn clicks_select_words_spaces_lines_paragraphs_and_the_story() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let text = "Hello brave world.\nSecond paragraph here.";
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": text})).unwrap();
|
||
let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());
|
||
// Spread point over the right half of the glyph at byte `b`.
|
||
let at = |s: &Session, b: usize| {
|
||
let d = &s.doc().unwrap().doc;
|
||
let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default());
|
||
let (l, g) = cs.frames[0].lines.iter().find_map(|l| l.glyphs.iter().find(|g| g.byte == b && g.len > 0).map(|g| (l, g))).unwrap();
|
||
json!([g.x + g.adv * 0.75, l.baseline - 3.0])
|
||
};
|
||
let mut select = |unit: &str, b: usize| {
|
||
let p = at(&s, b);
|
||
let r: Value = s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": unit})).unwrap();
|
||
(r["anchor"].as_u64().unwrap() as usize, r["focus"].as_u64().unwrap() as usize)
|
||
};
|
||
assert_eq!(select("word", 1), (0, 5), "inside a word");
|
||
assert_eq!(select("word", 4), (0, 5), "its last letter");
|
||
assert_eq!(select("word", 5), (5, 7), "the run of spaces after it");
|
||
assert_eq!(select("word", 18), (18, 19), "punctuation alone");
|
||
assert_eq!(select("line", 9), (0, 19));
|
||
assert_eq!(select("paragraph", 9), (0, 20), "with its return");
|
||
assert_eq!(select("paragraph", 22), (20, text.len()));
|
||
assert_eq!(select("story", 9), (0, text.len()));
|
||
// A second click inside the selected word selects again rather than starting a text drag.
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 7, "focus": 12})).unwrap();
|
||
let p = at(&s, 9);
|
||
s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "line"})).unwrap();
|
||
let t = s.doc().unwrap().selection.text.unwrap();
|
||
assert_eq!((t.anchor, t.focus), (0, 19));
|
||
s.execute("text.release", &json!({"frame": fid, "point": p, "moved": false})).unwrap();
|
||
let t = s.doc().unwrap().selection.text.unwrap();
|
||
assert_eq!((t.anchor, t.focus), (0, 19), "releasing keeps it");
|
||
// The older word command agrees.
|
||
let p = at(&s, 8);
|
||
s.execute("text.selectWord", &json!({"frame": fid, "point": p})).unwrap();
|
||
let t = s.doc().unwrap().selection.text.unwrap();
|
||
assert_eq!((t.anchor, t.focus), (7, 12));
|
||
assert!(s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "sentence"})).is_err());
|
||
assert!(s.execute("text.selectAt", &json!({"frame": fid, "unit": "word"})).is_err(), "no point");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod style_link_tests {
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
|
||
#[test]
|
||
fn bold_and_italic_follow_the_family_and_toggle() {
|
||
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": "plain words here"})).unwrap();
|
||
let sid = r["story"].as_u64().unwrap();
|
||
let style_at = |s: &mut Session, b: usize| {
|
||
s.execute("text.select", &json!({"story": sid, "anchor": b, "focus": b + 1})).unwrap();
|
||
s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"].as_str().unwrap().to_string()
|
||
};
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap();
|
||
s.execute("type.italic", &json!({})).unwrap();
|
||
assert_eq!(style_at(&mut s, 0), "Italic");
|
||
// Bold over italic and plain text: each run takes its own linked style.
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 11})).unwrap();
|
||
s.execute("type.bold", &json!({})).unwrap();
|
||
assert_eq!(style_at(&mut s, 0), "Bold Italic");
|
||
assert_eq!(style_at(&mut s, 7), "Bold");
|
||
// Again: off, back to each run's regular weight.
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 11})).unwrap();
|
||
s.execute("type.bold", &json!({})).unwrap();
|
||
assert_eq!(style_at(&mut s, 0), "Italic");
|
||
assert_eq!(style_at(&mut s, 7), "Regular");
|
||
// Nothing to change: an error and no undo step.
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 11})).unwrap();
|
||
let steps = s.doc().unwrap().history.undo.len();
|
||
assert!(s.execute("type.bold", &json!({"on": false})).is_err());
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||
// At a caret the style is for what is typed next.
|
||
let end = "plain words here".len();
|
||
s.execute("text.select", &json!({"story": sid, "anchor": end, "focus": end})).unwrap();
|
||
s.execute("type.bold", &json!({})).unwrap();
|
||
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"], "Bold");
|
||
s.execute("text.insert", &json!({"text": " now"})).unwrap();
|
||
assert_eq!(style_at(&mut s, end + 1), "Bold");
|
||
assert_eq!(style_at(&mut s, end - 1), "Regular", "text before the caret is unchanged");
|
||
// Moving the caret forgets it.
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 2, "focus": 2})).unwrap();
|
||
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 3})).unwrap();
|
||
s.execute("text.insert", &json!({"text": "x"})).unwrap();
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 4})).unwrap();
|
||
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 12.0);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod text_color_tests {
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
|
||
#[test]
|
||
fn unnamed_colours_go_to_text_when_asked() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "colour me", "caret": false})).unwrap();
|
||
let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());
|
||
s.execute("selection.set", &json!({"ids": [fid]})).unwrap();
|
||
let fill_of_frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(fid)).unwrap().fill.swatch.clone();
|
||
let before = fill_of_frame(&s);
|
||
let r = s.execute("object.color", &json!({"color": "#ff0000", "text": true})).unwrap();
|
||
let red = r["swatch"].as_str().unwrap().to_string();
|
||
assert_eq!(fill_of_frame(&s), before, "the frame keeps its fill");
|
||
let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone();
|
||
assert_eq!(story.char_format_at(3).over.fill.as_deref(), Some(red.as_str()));
|
||
// A text selection takes it without asking.
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap();
|
||
let r = s.execute("object.color", &json!({"color": "#00ff00", "target": "stroke"})).unwrap();
|
||
let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone();
|
||
assert_eq!(story.format_after(0).over.stroke.as_deref(), r["swatch"].as_str());
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), 3, "the frame, then one undo step per colour");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod drag_text_tests {
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
|
||
#[test]
|
||
fn drag_selected_text_to_move_or_copy_it() {
|
||
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": "Hello brave world"})).unwrap();
|
||
let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());
|
||
// Spread point of the glyph at byte `b`.
|
||
let at = |s: &Session, b: usize| {
|
||
let d = &s.doc().unwrap().doc;
|
||
let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default());
|
||
let l = &cs.frames[0].lines[0];
|
||
let g = l.glyphs.iter().find(|g| g.byte == b).unwrap();
|
||
json!([g.x + g.adv * 0.25, l.baseline - 3.0])
|
||
};
|
||
let end = |s: &Session| {
|
||
let d = &s.doc().unwrap().doc;
|
||
let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default());
|
||
let l = &cs.frames[0].lines[0];
|
||
json!([l.end_x + 2.0, l.baseline - 3.0])
|
||
};
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 12})).unwrap();
|
||
let r = s.execute("text.placeCaret", &json!({"frame": fid, "point": at(&s, 8)})).unwrap();
|
||
assert_eq!(r["dragging"], true);
|
||
s.execute("text.extendTo", &json!({"frame": fid, "point": end(&s)})).unwrap();
|
||
s.execute("text.release", &json!({"frame": fid, "point": end(&s), "moved": true})).unwrap();
|
||
let text = |s: &Session| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text.clone();
|
||
assert_eq!(text(&s), "Hello worldbrave ");
|
||
// Alt-drag copies (the moved text is still selected).
|
||
s.execute("text.placeCaret", &json!({"frame": fid, "point": at(&s, 12)})).unwrap();
|
||
s.execute("text.release", &json!({"frame": fid, "point": at(&s, 0), "moved": true, "copy": true})).unwrap();
|
||
assert_eq!(text(&s), "brave Hello worldbrave ");
|
||
// A click inside the selection just places the caret.
|
||
s.execute("text.placeCaret", &json!({"frame": fid, "point": at(&s, 2)})).unwrap();
|
||
s.execute("text.release", &json!({"frame": fid, "point": at(&s, 2), "moved": false})).unwrap();
|
||
let t = s.doc().unwrap().selection.text.unwrap();
|
||
assert_eq!(t.anchor, t.focus);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod nested_line_style_tests {
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
|
||
#[test]
|
||
fn first_line_takes_the_nested_line_style() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
s.execute("style.character.create", &json!({"name": "Lead", "chars": {"size": 18}})).unwrap();
|
||
let text = "The opening line of this paragraph reads larger, and the lines after it return to the body size again.";
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": text})).unwrap();
|
||
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
|
||
s.execute("type.para", &json!({"nestedLineStyles": [{"style": "Lead", "lines": 1}]})).unwrap();
|
||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||
let cs = s.cache.get(&s.doc().unwrap().doc, sid, None);
|
||
let lines = &cs.frames[0].lines;
|
||
assert!(lines.len() >= 3);
|
||
let size_of = |l: usize| cs.styles[lines[l].glyphs.iter().find(|g| g.visible).unwrap().style as usize].size;
|
||
assert_eq!(size_of(0), 18.0, "the first line");
|
||
assert!(
|
||
lines[0].glyphs.iter().filter(|g| g.visible && g.adv > 0.0).all(|g| cs.styles[g.style as usize].size == 18.0),
|
||
"the whole first line"
|
||
);
|
||
assert_eq!(size_of(1), 12.0, "the next lines");
|
||
let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap();
|
||
let st = back.stories.values().find(|st| st.text.starts_with("The opening")).unwrap();
|
||
let nl = st.paras[0].para.nested_line_styles.clone().unwrap_or_default();
|
||
assert_eq!((nl.len(), nl.first().map(|n| n.lines)), (1, Some(1)), "IDML AllNestedLineStyles");
|
||
assert_eq!(nl[0].style, "Lead");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod rule_tests {
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
|
||
#[test]
|
||
fn a_partial_rule_edit_keeps_the_other_fields() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let rule = json!({"on": true, "weight": 3.0, "color": "[Black]", "tint": 0.5, "columnWidth": false, "offset": 4.0, "leftIndent": 6.0, "rightIndent": 2.0});
|
||
s.execute("style.paragraph.create", &json!({"name": "Ruled", "para": {"ruleBelow": rule}})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": "One\nTwo"})).unwrap();
|
||
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
|
||
s.execute("style.paragraph.apply", &json!({"name": "Ruled"})).unwrap();
|
||
// Local: only the colour changes; the rest is inherited from the style.
|
||
s.execute("type.para", &json!({"attrs": {"ruleBelow": {"color": "[Registration]"}}})).unwrap();
|
||
let attrs = s.execute("type.selectionAttrs", &json!({})).unwrap();
|
||
let mut want = rule.clone();
|
||
want["color"] = json!("[Registration]");
|
||
assert_eq!(attrs["para"]["ruleBelow"], want);
|
||
assert_eq!(attrs["para"]["ruleAbove"]["on"], false, "the other rule is untouched");
|
||
// Style: only the weight changes.
|
||
s.execute("style.paragraph.edit", &json!({"name": "Ruled", "para": {"ruleBelow": {"weight": 1.5}}})).unwrap();
|
||
let st = s.doc().unwrap().doc.styles.para("Ruled").cloned().unwrap();
|
||
let below = serde_json::to_value(st.para.rule_below.unwrap()).unwrap();
|
||
let mut want = rule.clone();
|
||
want["weight"] = json!(1.5);
|
||
assert_eq!(below, want);
|
||
assert!(s.execute("type.para", &json!({"attrs": {"ruleBelow": {"thickness": 2}}})).is_err(), "unknown rule field");
|
||
}
|
||
|
||
#[test]
|
||
fn rule_above_width_follows_column_or_text() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Short"})).unwrap();
|
||
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
|
||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||
let mut widths = vec![];
|
||
for column in [true, false] {
|
||
s.execute("type.para", &json!({"ruleAbove": {"on": true, "columnWidth": column}})).unwrap();
|
||
let cs = s.cache.get(&s.doc().unwrap().doc, sid, None);
|
||
widths.push(cs.frames[0].decos[0].rect.width());
|
||
}
|
||
assert!((widths[0] - 328.0).abs() < 1e-6, "column: {widths:?}");
|
||
assert!(widths[1] < 100.0, "text: {widths:?}");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod border_tests {
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
|
||
#[test]
|
||
fn paragraph_border_and_shading_follow_each_column() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let text = "word ".repeat(80);
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 160], "content": "text", "text": text, "caret": false})).unwrap();
|
||
s.execute("object.textFrameOptions", &json!({"ids": [r["id"]], "columns": 2})).unwrap();
|
||
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
|
||
s.execute("type.para", &json!({"borderOn": true, "borderWeights": [2, 1, 2, 1], "borderOffsets": [4, 4, 4, 4], "borderColor": "[Black]", "shadingOn": true, "shadingOffsets": [4, 4, 4, 4]})).unwrap();
|
||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||
let cs = s.cache.get(&s.doc().unwrap().doc, sid, None);
|
||
let decos = &cs.frames[0].decos;
|
||
let shading: Vec<_> = decos.iter().filter(|d| d.tint < 0.5).collect();
|
||
assert_eq!(shading.len(), 2, "one shaded box per column");
|
||
let edges = decos.len() - shading.len();
|
||
assert_eq!(edges, 6, "top on the first part, bottom on the last, sides on both");
|
||
let top = decos.iter().filter(|d| d.tint >= 0.5).map(|d| d.rect).fold(f64::MAX, |a, r| a.min(r.y0));
|
||
let first_line = &cs.frames[0].lines[0];
|
||
assert!((top - (first_line.baseline - first_line.ascent - 4.0 - 2.0)).abs() < 1e-6, "offset 4 + weight 2 above the text");
|
||
let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap();
|
||
let p = &back.stories.values().find(|st| st.text.starts_with("word")).unwrap().paras[0].para;
|
||
assert_eq!(
|
||
(p.border_on, p.border_weights, p.shading_offsets),
|
||
(Some(true), Some([2.0, 1.0, 2.0, 1.0]), Some([4.0; 4])),
|
||
"IDML paragraph border"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod autocorrect_tests {
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
|
||
#[test]
|
||
fn autocorrect_fixes_the_word_before_a_space() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap();
|
||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap();
|
||
let text = |s: &Session| s.doc().unwrap().doc.stories[&sid].text.clone();
|
||
// Off by default.
|
||
for c in ["t", "e", "h", " "] {
|
||
s.execute("text.insert", &json!({"text": c})).unwrap();
|
||
}
|
||
assert_eq!(text(&s), "teh ");
|
||
s.execute("prefs.set", &json!({"autocorrect": true})).unwrap();
|
||
for c in ["T", "e", "h", " ", "c", "a", "t", "."] {
|
||
s.execute("text.insert", &json!({"text": c})).unwrap();
|
||
}
|
||
assert_eq!(text(&s), "teh The cat.");
|
||
// The caret ends after the inserted punctuation.
|
||
assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, "teh The cat.".len());
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod break_tests {
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
|
||
/// Type › Insert Break Character: the break ends its paragraph, the caret starts the next one.
|
||
#[test]
|
||
fn inserted_break_ends_its_paragraph() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "OneTwo\nThree"})).unwrap();
|
||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||
let story = |s: &Session| s.doc().unwrap().doc.stories[&sid].clone();
|
||
s.execute("text.select", &json!({"story": sid.0, "anchor": 3, "focus": 3})).unwrap();
|
||
s.execute("text.insert", &json!({"text": "\u{e011}", "raw": true})).unwrap();
|
||
assert_eq!(story(&s).text, "One\u{e011}\nTwo\nThree");
|
||
assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, "One\u{e011}\n".len());
|
||
// At the end of a paragraph the break takes the place of its return.
|
||
let at = story(&s).text.find("\nThree").unwrap();
|
||
s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": at})).unwrap();
|
||
s.execute("text.insert", &json!({"text": "\u{e002}", "raw": true})).unwrap();
|
||
let st = story(&s);
|
||
assert_eq!(st.text, "One\u{e011}\nTwo\u{e002}\nThree");
|
||
assert_eq!(st.paras.len(), 3);
|
||
st.check().unwrap();
|
||
// Typing after the break goes to the next paragraph; deleting the break or its return
|
||
// removes both.
|
||
let at = "One\u{e011}".len();
|
||
s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": at})).unwrap();
|
||
s.execute("text.insert", &json!({"text": "x"})).unwrap();
|
||
assert_eq!(story(&s).text, "One\u{e011}\nxTwo\u{e002}\nThree");
|
||
s.execute("text.select", &json!({"story": sid.0, "anchor": at + 1, "focus": at + 1})).unwrap();
|
||
s.execute("text.delete", &json!({})).unwrap();
|
||
assert_eq!(story(&s).text, "OnexTwo\u{e002}\nThree");
|
||
s.execute("text.select", &json!({"story": sid.0, "anchor": "OnexTwo".len(), "focus": "OnexTwo".len()})).unwrap();
|
||
s.execute("text.delete", &json!({"forward": true})).unwrap();
|
||
assert_eq!(story(&s).text, "OnexTwoThree");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod language_tests {
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
|
||
#[test]
|
||
fn language_sets_quotes_spelling_and_hyphenation() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap();
|
||
let sid = r["story"].as_u64().unwrap();
|
||
s.execute("type.char", &json!({"attrs": {"language": "German: 2006 Reform"}})).unwrap();
|
||
s.execute("text.insert", &json!({"text": "\"Wort\" ist's"})).unwrap();
|
||
let text = s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].text.clone();
|
||
assert_eq!(text, "\u{201E}Wort\u{201C} ist\u{2019}s");
|
||
// German words aren't English misspellings.
|
||
let st = s.execute("spelling.check", &json!({"story": sid})).unwrap();
|
||
assert!(st.as_array().unwrap().is_empty(), "{st}");
|
||
}
|
||
|
||
#[test]
|
||
fn cjk_languages_have_their_quotes_and_no_english_rules() {
|
||
use super::quote_marks;
|
||
assert_eq!(quote_marks("Chinese: Simplified"), ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']);
|
||
assert_eq!(quote_marks("Chinese: Traditional"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']);
|
||
assert_eq!(quote_marks("Chinese"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']);
|
||
assert_eq!(quote_marks("Japanese"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']);
|
||
// Other spellings of the language names take the same quotes.
|
||
assert_eq!(quote_marks("Simplified Chinese"), ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']);
|
||
assert_eq!(quote_marks("zh_CN"), ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']);
|
||
assert_eq!(quote_marks("Chinese (Traditional)"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']);
|
||
assert_eq!(quote_marks("zh-TW"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']);
|
||
assert_eq!(quote_marks("ja_JP"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']);
|
||
for l in ["Japanese", "Korean", "Chinese", "Chinese: Simplified", "Chinese: Traditional"] {
|
||
assert!(!designcraft_compose::is_english(l), "{l}: no English hyphenation or spelling");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn locale_coded_languages_take_their_quotes_and_rules() {
|
||
use super::quote_marks;
|
||
const GERMAN: [char; 4] = ['\u{201E}', '\u{201C}', '\u{201A}', '\u{2018}'];
|
||
const DUTCH: [char; 4] = ['\u{201E}', '\u{201D}', '\u{201A}', '\u{2019}'];
|
||
const GUILLEMETS: [char; 4] = ['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}'];
|
||
const SPANISH: [char; 4] = ['\u{00AB}', '\u{00BB}', '\u{201C}', '\u{201D}'];
|
||
const ENGLISH: [char; 4] = ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}'];
|
||
const CORNERS: [char; 4] = ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}'];
|
||
for (language, quotes, english) in [
|
||
("de_DE_2006", GERMAN, false),
|
||
("German: 2006 Reform", GERMAN, false),
|
||
("de_CH_2006", GUILLEMETS, false),
|
||
("German: Swiss 2006 Reform", GUILLEMETS, false),
|
||
("nl_NL_2005", DUTCH, false),
|
||
("Dutch: 2005 Reform", DUTCH, false),
|
||
("English: USA", ENGLISH, true),
|
||
("English: UK", ENGLISH, true),
|
||
("en_GB", ENGLISH, true),
|
||
("Spanish: Castilian", SPANISH, false),
|
||
("es_ES", SPANISH, false),
|
||
("French", GUILLEMETS, false),
|
||
("fr_FR", GUILLEMETS, false),
|
||
("nb_NO", GUILLEMETS, false),
|
||
("ja_JP", CORNERS, false),
|
||
("[No Language]", ENGLISH, false),
|
||
] {
|
||
assert_eq!(quote_marks(language), quotes, "{language}");
|
||
assert_eq!(designcraft_compose::is_english(language), english, "{language}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn idml_language_names_keep_their_spelling_and_pick_the_language() {
|
||
use std::io::Read;
|
||
// A stand-in for Songti SC (first in the Simplified Chinese chain; the real one when installed).
|
||
designcraft_fonts::FontDb::global().add_font(designcraft_fonts::testing::font_with("Songti SC", &['直']).unwrap());
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "直"})).unwrap();
|
||
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 3})).unwrap();
|
||
s.execute("type.char", &json!({"attrs": {"language": "Simplified Chinese"}})).unwrap();
|
||
let idml = designcraft_idml::export_idml(&s.doc().unwrap().doc);
|
||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(idml.clone())).unwrap();
|
||
let stories: String = (0..z.len())
|
||
.map(|i| {
|
||
let mut f = z.by_index(i).unwrap();
|
||
let mut x = String::new();
|
||
if f.name().starts_with("Stories/") {
|
||
f.read_to_string(&mut x).unwrap();
|
||
}
|
||
x
|
||
})
|
||
.collect();
|
||
assert!(stories.contains(r#"AppliedLanguage="$ID/Simplified Chinese""#), "{stories}");
|
||
// The name comes back as written and sets the text as Simplified Chinese.
|
||
let mut back = designcraft_idml::import_idml(&idml).unwrap();
|
||
back.settings.glyph_fallback = true;
|
||
let st = back.stories.values().find(|st| st.text.contains('直')).unwrap();
|
||
let (_, base) = back.styles.resolve_para(&st.paras[0]);
|
||
assert_eq!(back.styles.resolve_char(&base, st.char_format_at(0)).language, "Simplified Chinese");
|
||
let cs = designcraft_compose::compose_story(&back, st.id, &Default::default());
|
||
let face = cs.frames[0].lines[0].glyphs.iter().find(|g| g.byte == 0).unwrap().face;
|
||
assert_eq!(face.family, "Songti SC");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tcy_tests {
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
|
||
#[test]
|
||
fn tate_chu_yoko_toggles_on_the_selection() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 400], "content": "text", "text": "令和12年", "vertical": true})).unwrap();
|
||
let sid = r["story"].as_u64().unwrap();
|
||
let at = "令和".len();
|
||
s.execute("text.select", &json!({"story": sid, "anchor": at, "focus": at + 2})).unwrap();
|
||
let get = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(at + 1).over.tate_chu_yoko;
|
||
s.execute("type.tateChuYoko", &json!({})).unwrap();
|
||
assert_eq!(get(&s), Some(true));
|
||
s.execute("type.tateChuYoko", &json!({})).unwrap();
|
||
assert_eq!(get(&s), Some(false));
|
||
s.execute("type.tateChuYoko", &json!({"on": true})).unwrap();
|
||
assert_eq!(get(&s), Some(true));
|
||
}
|
||
|
||
#[test]
|
||
fn ruby_and_kenten_on_the_selection() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "漢字です"})).unwrap();
|
||
let sid = r["story"].as_u64().unwrap();
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": "漢字".len()})).unwrap();
|
||
s.execute("type.ruby", &json!({"text": "かんじ"})).unwrap();
|
||
s.execute("type.kenten", &json!({})).unwrap();
|
||
let f = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(1).over.clone();
|
||
assert_eq!(f(&s).ruby.as_deref(), Some("かんじ"));
|
||
assert_eq!(f(&s).kenten, Some(true));
|
||
s.execute("type.kenten", &json!({})).unwrap();
|
||
s.execute("type.ruby", &json!({"text": ""})).unwrap();
|
||
assert_eq!(f(&s).kenten, Some(false));
|
||
assert_eq!(f(&s).ruby.as_deref(), Some(""));
|
||
}
|
||
|
||
#[test]
|
||
fn ruby_options_are_checked_and_clamped() {
|
||
use designcraft_doc::ruby::{RubyAlignment, RubyPosition, RubyType};
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "漢字です"})).unwrap();
|
||
let sid = r["story"].as_u64().unwrap();
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": "漢字".len()})).unwrap();
|
||
let f = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(1).over.clone();
|
||
s.execute(
|
||
"type.ruby",
|
||
&json!({"text": "かん\u{3000}じ", "type": "perCharacter", "alignment": "oneAki", "position": "belowLeft", "size": 1e9, "xScale": -50, "scalingPercent": 5, "autoTcyDigits": 99, "fillTint": 250, "yOffset": -1e12, "fill": "[Black]"}),
|
||
)
|
||
.unwrap();
|
||
let a = f(&s);
|
||
assert_eq!(
|
||
(a.ruby.as_deref(), a.ruby_type, a.ruby_position),
|
||
(Some("かん\u{3000}じ"), Some(RubyType::PerCharacter), Some(RubyPosition::BelowLeft))
|
||
);
|
||
assert_eq!(a.ruby_font_size, Some(Some(1296.0)));
|
||
assert_eq!(a.ruby_x_scale, Some(0.01));
|
||
assert_eq!(a.ruby_scaling_min, Some(0.1));
|
||
assert_eq!(a.ruby_auto_tcy_digits, Some(9));
|
||
assert_eq!(a.ruby_fill_tint, Some(Some(1.0)));
|
||
assert_eq!(a.ruby_y_offset, Some(-1000.0));
|
||
// Options alone keep the reading.
|
||
s.execute("type.ruby", &json!({"alignment": "center"})).unwrap();
|
||
assert_eq!((f(&s).ruby, f(&s).ruby_alignment), (a.ruby.clone(), Some(RubyAlignment::Center)));
|
||
// Refused, and nothing changes: unknown names, non-numbers, overlong text, unknown swatches.
|
||
let long = "あ".repeat(designcraft_doc::ruby::MAX_RUBY_CHARS + 1);
|
||
for p in [
|
||
json!({"alignment": "sideways"}),
|
||
json!({"size": "big"}),
|
||
json!({"text": long}),
|
||
json!({"fill": "Nope"}),
|
||
json!({"autoAlign": 1}),
|
||
json!({"text": 5}),
|
||
] {
|
||
assert!(s.execute("type.ruby", &p).is_err(), "{p}");
|
||
}
|
||
assert_eq!(f(&s).ruby_alignment, Some(RubyAlignment::Center));
|
||
assert_eq!(f(&s).ruby, a.ruby);
|
||
}
|
||
|
||
#[test]
|
||
fn warichu_toggles_on_the_selection_and_undoes() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "注釈です"})).unwrap();
|
||
let sid = r["story"].as_u64().unwrap();
|
||
let n = "注釈".len();
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": n})).unwrap();
|
||
s.execute("type.warichu", &json!({"lines": 2, "size": 40, "align": "left", "lineSpacing": 1, "charsBeforeBreak": 2, "charsAfterBreak": 2}))
|
||
.unwrap();
|
||
let f = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(1).over.clone();
|
||
let a = f(&s);
|
||
assert_eq!(a.warichu, Some(true));
|
||
assert_eq!(a.warichu_lines, Some(2));
|
||
assert_eq!(a.warichu_size, Some(40.0));
|
||
assert_eq!(a.warichu_line_spacing, Some(1.0));
|
||
assert_eq!(a.warichu_alignment, Some(designcraft_doc::cjk::WarichuAlignment::Left));
|
||
assert_eq!(a.warichu_chars_before_break, Some(2));
|
||
assert_eq!(a.warichu_chars_after_break, Some(2));
|
||
// The characters after the selection are not part of the note.
|
||
assert_eq!(s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(n + 1).over.warichu, None);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(f(&s).warichu, None);
|
||
s.execute("edit.redo", &json!({})).unwrap();
|
||
assert_eq!(f(&s).warichu, Some(true));
|
||
s.execute("type.warichu", &json!({})).unwrap();
|
||
assert_eq!(f(&s).warichu, Some(false));
|
||
}
|
||
|
||
#[test]
|
||
fn warichu_up_and_down_move_between_the_rows() {
|
||
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": "ABCDEFGHZ"})).unwrap();
|
||
let sid = r["story"].as_u64().unwrap();
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 8})).unwrap();
|
||
s.execute("type.warichu", &json!({"lines": 2, "align": "left"})).unwrap();
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
|
||
let down = s.execute("text.move", &json!({"dir": "down"})).unwrap()["pos"].as_u64().unwrap() as usize;
|
||
assert!((4..8).contains(&down), "down enters the second row: {down}");
|
||
let up = s.execute("text.move", &json!({"dir": "up"})).unwrap()["pos"].as_u64().unwrap() as usize;
|
||
assert!(up < 4, "up returns to the first row: {up}");
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 8, "focus": 8})).unwrap();
|
||
let stayed = s.execute("text.move", &json!({"dir": "down"})).unwrap()["pos"].as_u64().unwrap() as usize;
|
||
assert_eq!(stayed, 8, "down from the character after the note does not fall back into a row");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod bidi_caret_tests {
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
|
||
#[test]
|
||
fn arrow_keys_in_a_right_to_left_paragraph() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "سلام عليكم"})).unwrap();
|
||
let sid = r["story"].as_u64().unwrap();
|
||
s.execute("type.para", &json!({"attrs": {"direction": "rightToLeft"}})).unwrap();
|
||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
|
||
// Left moves forward through right-to-left text, Right back.
|
||
let p = s.execute("text.move", &json!({"dir": "left"})).unwrap()["pos"].as_u64().unwrap();
|
||
assert_eq!(p, 'س'.len_utf8() as u64);
|
||
let p = s.execute("text.move", &json!({"dir": "right"})).unwrap()["pos"].as_u64().unwrap();
|
||
assert_eq!(p, 0);
|
||
// By words too.
|
||
let p = s.execute("text.move", &json!({"dir": "left", "word": true})).unwrap()["pos"].as_u64().unwrap();
|
||
assert!(p >= "سلام".len() as u64, "{p}");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod arabic_tests {
|
||
use super::*;
|
||
#[test]
|
||
fn arabic_story_direction_command_validates_and_supports_undo() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "abc"})).unwrap();
|
||
let sid = StoryId(r["story"].as_u64().unwrap());
|
||
let before = s.cache.get(&s.doc().unwrap().doc, sid, None);
|
||
s.execute("story.setDirection", &json!({"story": sid.0, "direction": "rightToLeft"})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().direction, designcraft_doc::TextDirection::RightToLeft);
|
||
let after = s.cache.get(&s.doc().unwrap().doc, sid, None);
|
||
assert!(!std::sync::Arc::ptr_eq(&before, &after));
|
||
assert!(s.execute("story.setDirection", &json!({"story": sid.0, "direction": "unknown"})).is_err());
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().direction, designcraft_doc::TextDirection::LeftToRight);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod korean_breaks_tests {
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
|
||
#[test]
|
||
fn korean_character_breaks_are_a_paragraph_attribute() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "한국어 문장"})).unwrap();
|
||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||
s.execute("type.para", &json!({"attrs": {"koreanCharBreaks": true}})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().paras[0].para.korean_char_breaks, Some(true));
|
||
s.execute("style.paragraph.create", &json!({"name": "Korean", "para": {"koreanCharBreaks": true}})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.styles.para("Korean").unwrap().para.korean_char_breaks, Some(true));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod story_direction_tests {
|
||
use designcraft_doc::{ItemId, StoryId};
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
|
||
fn frame(s: &mut Session, rect: [f64; 4], text: &str, vertical: bool) -> (ItemId, StoryId) {
|
||
let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "caret": false, "vertical": vertical})).unwrap();
|
||
(ItemId(r["id"].as_u64().unwrap()), StoryId(r["story"].as_u64().unwrap()))
|
||
}
|
||
|
||
/// Which frames of the story are composed vertically, in thread order.
|
||
fn composed(s: &Session, sid: StoryId) -> Vec<bool> {
|
||
s.cache.get(&s.doc().unwrap().doc, sid, None).frames.iter().map(|f| f.vertical).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn story_direction_turns_every_frame_of_the_story() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let (a, sid) = frame(&mut s, [72.0, 72.0, 200.0, 400.0], "縦書きの文章です。", false);
|
||
let (b, _) = frame(&mut s, [300.0, 72.0, 428.0, 400.0], "", false);
|
||
s.edit(|d, _| Ok(d.thread(a, b)?)).unwrap();
|
||
assert_eq!(composed(&s, sid), [false, false]);
|
||
// From the second frame selected, through Type ▸ Story Direction.
|
||
s.execute("selection.set", &json!({"ids": [b.0]})).unwrap();
|
||
s.execute("type.storyDirection", &json!({"vertical": true})).unwrap();
|
||
assert_eq!(composed(&s, sid), [true, true]);
|
||
// From a caret in the text.
|
||
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap();
|
||
s.execute("type.storyDirection", &json!({"vertical": false})).unwrap();
|
||
assert_eq!(composed(&s, sid), [false, false]);
|
||
// The frame option sets the frame's story.
|
||
s.execute("object.textFrameOptions", &json!({"ids": [a.0], "vertical": true})).unwrap();
|
||
assert_eq!(composed(&s, sid), [true, true]);
|
||
// Undo puts it back.
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(composed(&s, sid), [false, false]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_frame_threaded_onto_a_vertical_story_turns_vertical() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let (a, sid) = frame(&mut s, [72.0, 72.0, 200.0, 400.0], "縦書きの文章です。", true);
|
||
let (b, _) = frame(&mut s, [300.0, 72.0, 428.0, 400.0], "", false);
|
||
let c = s.execute("frame.create", &json!({"rect": [450, 72, 550, 400]})).unwrap();
|
||
let c = ItemId(c["id"].as_u64().unwrap());
|
||
s.edit(|d, _| {
|
||
d.thread(a, b)?;
|
||
Ok(d.thread(b, c)?)
|
||
})
|
||
.unwrap();
|
||
assert_eq!(composed(&s, sid), [true, true, true]);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod vertical_caret_tests {
|
||
use designcraft_doc::StoryId;
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
|
||
/// A story set in three or more lines; returns its id.
|
||
fn story(s: &mut Session, vertical: bool) -> StoryId {
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let text = "一二三四五六七八九十".repeat(8);
|
||
let rect = if vertical { [72, 72, 200, 400] } else { [72, 72, 400, 200] };
|
||
let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "vertical": vertical})).unwrap();
|
||
StoryId(r["story"].as_u64().unwrap())
|
||
}
|
||
|
||
fn mv(s: &mut Session, dir: &str, extend: bool, word: bool) -> usize {
|
||
s.execute("text.move", &json!({"dir": dir, "extend": extend, "word": word})).unwrap()["pos"].as_u64().unwrap() as usize
|
||
}
|
||
|
||
/// (line index, position along the line) of the caret at `pos`.
|
||
fn place(s: &Session, sid: StoryId, pos: usize) -> (usize, f64) {
|
||
let cs = s.cache.get(&s.doc().unwrap().doc, sid, None);
|
||
let (_, x, baseline, _, _) = crate::compose::caret(&cs, pos).unwrap();
|
||
let line = cs.frames[0].lines.iter().position(|l| (l.baseline - baseline).abs() < 0.01).unwrap();
|
||
(line, x)
|
||
}
|
||
|
||
#[test]
|
||
fn arrow_keys_follow_vertical_lines() {
|
||
let mut s = Session::new();
|
||
let sid = story(&mut s, true);
|
||
assert!(s.cache.get(&s.doc().unwrap().doc, sid, None).frames[0].lines.len() >= 3);
|
||
let c = '一'.len_utf8();
|
||
s.execute("text.select", &json!({"story": sid.0, "anchor": 3 * c, "focus": 3 * c})).unwrap();
|
||
// Down and Up move along the line, a character at a time.
|
||
assert_eq!(mv(&mut s, "down", false, false), 4 * c);
|
||
assert_eq!(mv(&mut s, "up", false, false), 3 * c);
|
||
// Left moves to the next line (lines follow each other right to left), keeping the
|
||
// position along the line; Right comes back.
|
||
let (line, x) = place(&s, sid, 3 * c);
|
||
let next = mv(&mut s, "left", false, false);
|
||
let (nline, nx) = place(&s, sid, next);
|
||
assert_eq!(nline, line + 1);
|
||
assert!((nx - x).abs() < 0.5, "{x} {nx}");
|
||
assert_eq!(mv(&mut s, "right", false, false), 3 * c);
|
||
// Shift extends.
|
||
assert_eq!(mv(&mut s, "down", true, false), 4 * c);
|
||
let t = s.doc().unwrap().selection.text.unwrap();
|
||
assert_eq!((t.anchor, t.focus), (3 * c, 4 * c));
|
||
let p = mv(&mut s, "left", true, false);
|
||
let t = s.doc().unwrap().selection.text.unwrap();
|
||
assert_eq!((t.anchor, t.focus), (3 * c, p));
|
||
assert_eq!(place(&s, sid, p).0, line + 1);
|
||
// With a selection, Up collapses it to its start.
|
||
assert_eq!(mv(&mut s, "up", false, false), 3 * c);
|
||
// By words: Alt+Down is the vertical Alt+Right.
|
||
let text = s.doc().unwrap().doc.story(sid).unwrap().text.clone();
|
||
assert_eq!(mv(&mut s, "down", false, true), super::next_word(&text, 3 * c));
|
||
}
|
||
|
||
#[test]
|
||
fn arrow_keys_in_horizontal_lines() {
|
||
let mut s = Session::new();
|
||
let sid = story(&mut s, false);
|
||
let c = '一'.len_utf8();
|
||
s.execute("text.select", &json!({"story": sid.0, "anchor": 3 * c, "focus": 3 * c})).unwrap();
|
||
assert_eq!(mv(&mut s, "right", false, false), 4 * c);
|
||
assert_eq!(mv(&mut s, "left", false, false), 3 * c);
|
||
let (line, x) = place(&s, sid, 3 * c);
|
||
let next = mv(&mut s, "down", false, false);
|
||
let (nline, nx) = place(&s, sid, next);
|
||
assert_eq!(nline, line + 1);
|
||
assert!((nx - x).abs() < 0.5, "{x} {nx}");
|
||
assert_eq!(mv(&mut s, "up", false, false), 3 * c);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod story_query_contract_tests {
|
||
use serde_json::json;
|
||
#[test]
|
||
fn missing_story_errors_but_valid_empty_and_selected_stories_succeed() {
|
||
let mut s = crate::Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
assert!(s.execute("story.get", &json!({"story":999})).unwrap_err().to_string().contains("no such story"));
|
||
let f = s.execute("frame.create", &json!({"rect":[36,36,300,200],"content":"text"})).unwrap();
|
||
assert_eq!(s.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "");
|
||
assert_eq!(s.execute("story.get", &json!({"frame":f["id"]})).unwrap()["text"], "");
|
||
s.execute("text.select", &json!({"story":f["story"],"anchor":0})).unwrap();
|
||
assert_eq!(s.execute("story.get", &json!({})).unwrap()["text"], "");
|
||
}
|
||
|
||
#[test]
|
||
fn kenten_shatai_auto_tcy_and_grid_settings_take_checked_values() {
|
||
use designcraft_doc::cjk::CharacterAlignment;
|
||
use designcraft_doc::cjk_settings::{KentenKind, KentenPosition};
|
||
let mut s = crate::Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 400], "content": "text", "text": "縦書き12年", "vertical": true})).unwrap();
|
||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": "縦書き".len()})).unwrap();
|
||
let chars = |s: &crate::Session| s.doc().unwrap().doc.stories[&sid].char_format_at(1).over.clone();
|
||
let para = |s: &crate::Session| s.doc().unwrap().doc.stories[&sid].paras[0].para.clone();
|
||
// Any setting turns kenten on; values are clamped and text is capped.
|
||
let long = "★".repeat(10_000);
|
||
s.execute(
|
||
"type.kenten",
|
||
&json!({"kind": "custom", "character": long, "size": 1e300, "distance": -1e300, "xScale": 0, "fillTint": 250, "position": "belowLeft"}),
|
||
)
|
||
.unwrap();
|
||
let a = chars(&s);
|
||
assert_eq!((a.kenten, a.kenten_kind, a.kenten_position), (Some(true), Some(KentenKind::Custom), Some(KentenPosition::BelowLeft)));
|
||
assert_eq!(a.kenten_character.as_ref().map(|c| c.chars().count()), Some(designcraft_doc::cjk_settings::KENTEN_MARK_MAX_CHARS));
|
||
assert_eq!(
|
||
(a.kenten_size, a.kenten_distance, a.kenten_x_scale, a.kenten_fill_tint),
|
||
(Some(Some(1296.0)), Some(-1296.0), Some(0.01), Some(Some(1.0)))
|
||
);
|
||
// Bad values change nothing.
|
||
for bad in [json!({"kind": "sparkle"}), json!({"size": "big"}), json!({"character": 5}), json!({"on": "yes"}), json!({"overprintFill": 1})] {
|
||
assert!(s.execute("type.kenten", &bad).is_err(), "{bad}");
|
||
}
|
||
assert_eq!(chars(&s), a);
|
||
s.execute("type.kenten", &json!({"on": false})).unwrap();
|
||
assert_eq!((chars(&s).kenten, chars(&s).kenten_kind), (Some(false), Some(KentenKind::Custom)), "off keeps the settings");
|
||
// Shatai.
|
||
s.execute("type.shatai", &json!({"magnification": 1e9, "angle": -30, "adjustRotation": true})).unwrap();
|
||
let a = chars(&s);
|
||
assert_eq!((a.shatai_magnification, a.shatai_angle, a.shatai_adjust_rotation), (Some(90.0), Some(150.0), Some(true)));
|
||
assert!(s.execute("type.shatai", &json!({})).is_err());
|
||
assert!(s.execute("type.shatai", &json!({"angle": [45]})).is_err());
|
||
// Paragraph settings.
|
||
s.execute("type.autoTateChuYoko", &json!({"digits": 1e12, "includeRoman": true})).unwrap();
|
||
assert_eq!((para(&s).auto_tcy, para(&s).auto_tcy_include_roman), (Some(designcraft_compose::shape::AUTO_TCY_MAX), Some(true)));
|
||
s.execute("type.gridSettings", &json!({"alignment": "emCenter", "firstLineOnly": true, "gyoudori": 1e12, "paragraphGyoudori": true}))
|
||
.unwrap();
|
||
let p = para(&s);
|
||
assert_eq!((p.grid_align, p.grid_reference), (Some(designcraft_doc::GridAlign::FirstLineOnly), Some(CharacterAlignment::EmCenter)));
|
||
assert_eq!((p.grid_gyoudori, p.paragraph_gyoudori), (Some(100), Some(true)));
|
||
assert!(s.execute("type.gridSettings", &json!({"alignment": "middle"})).is_err());
|
||
s.execute("type.gridSettings", &json!({"alignment": "none"})).unwrap();
|
||
assert_eq!(para(&s).grid_align, Some(designcraft_doc::GridAlign::None));
|
||
s.execute("type.rotateRoman", &json!({})).unwrap();
|
||
assert_eq!(para(&s).rotate_roman, Some(true));
|
||
s.execute("type.rotateRoman", &json!({})).unwrap();
|
||
assert_eq!(para(&s).rotate_roman, Some(false));
|
||
s.execute("type.romanWordBreak", &json!({"on": true})).unwrap();
|
||
assert_eq!(para(&s).roman_word_break, Some(true));
|
||
// The story still composes with every setting at its limit.
|
||
let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default());
|
||
assert!(!cs.frames.is_empty());
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod reserve_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn failed_reservation_rolls_back_the_edit_even_after_local_text_was_removed() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let result = s.execute("frame.create", &json!({"rect":[36,36,300,300],"content":"text","text":"Keep this text."})).unwrap();
|
||
let sid = StoryId(result["story"].as_u64().unwrap());
|
||
s.execute("text.select", &json!({"story":sid.0,"anchor":1,"focus":4})).unwrap();
|
||
let before = s.doc().unwrap().clone();
|
||
assert!(before.selection.text.is_some_and(|t| !t.range().is_empty()));
|
||
let journal = s.journal.clone();
|
||
let error = s
|
||
.edit(|d, sel| {
|
||
let st = d.story_mut(sid).unwrap();
|
||
st.format_chars(5..9, |f| f.over.change = Some(designcraft_doc::ChangeMark::Inserted));
|
||
let at = super::super::changes::mark_deleted(st, 0..9);
|
||
assert_eq!(at, 5);
|
||
assert_eq!(st.text, "Keep text.");
|
||
assert_eq!(st.format_after(0).over.change, Some(designcraft_doc::ChangeMark::Deleted));
|
||
sel.text = None;
|
||
reserve_command_text(st, usize::MAX)?;
|
||
ok()
|
||
})
|
||
.unwrap_err();
|
||
assert!(error.to_string().contains("could not reserve text insertion"));
|
||
let after = s.doc().unwrap();
|
||
assert!(std::sync::Arc::ptr_eq(&after.doc, &before.doc));
|
||
assert_eq!(after.selection, before.selection);
|
||
assert_eq!(after.revision, before.revision);
|
||
assert_eq!(after.history.undo.len(), before.history.undo.len());
|
||
assert_eq!(after.history.redo.len(), before.history.redo.len());
|
||
assert_eq!(s.journal, journal);
|
||
}
|
||
}
|