mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 19:57:05 -06:00
@@ -89,6 +89,8 @@ fn insert_inline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
let style = edit::insertion_style(&t.runs, a, b);
|
||||
let caret = edit::replace_range_styled(&mut t.runs, a, b, &[TextRun::inline(art, style)]);
|
||||
// The inline character is one plain-text byte range: ruby over it follows the text.
|
||||
t.ruby_after_replace(a, b, vectorcraft_doc::text::INLINE_CHAR.len_utf8());
|
||||
refresh_bounds(t);
|
||||
Ok(caret)
|
||||
})?;
|
||||
|
||||
@@ -209,6 +209,8 @@ fn edit_range(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(r) => edit::replace_range_styled(&mut t.runs, a, b, r),
|
||||
None => edit::replace_range(&mut t.runs, a, b, &insert),
|
||||
};
|
||||
// Ruby over the edited text moves with it (its ranges are plain-text bytes).
|
||||
t.ruby_after_replace(a, b, inserted.len());
|
||||
refresh_bounds(t);
|
||||
Ok(caret)
|
||||
})?;
|
||||
@@ -849,6 +851,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
kind,
|
||||
xf: Affine::IDENTITY,
|
||||
runs: vec![TextRun { text, style, inline: None }],
|
||||
ruby: Vec::new(),
|
||||
para: super::create::new_type_para(),
|
||||
paras: Vec::new(),
|
||||
area: Default::default(),
|
||||
@@ -1070,6 +1073,8 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
|
||||
kind: TextKind::Point,
|
||||
xf: Affine::IDENTITY,
|
||||
runs: head.clone(),
|
||||
// Ruby over the counted part; the layout clamps it to `head`.
|
||||
ruby: t.ruby.clone(),
|
||||
para: Default::default(),
|
||||
paras: Vec::new(),
|
||||
area: Default::default(),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, RubyAlign, RubyPosition, RubySpan, TextObject};
|
||||
use vectorcraft_geom::{Affine, PathData, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
@@ -106,6 +106,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
set_style
|
||||
),
|
||||
cmd!(
|
||||
"type.ruby",
|
||||
"Ruby…",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, start?: byte, end?: byte, text?: reading, position?: above|below (vertical type: right|left), align?: start|center|end, size?: 1–100 (percent of the base text's size, default 50), overhang?: bool (default true), remove?: bool} set the ruby (furigana) over the selected text's bytes in `start..end` (without them: all its text): the reading over the base characters, above them in horizontal type and right of them in vertical type; `remove` takes the ruby off the range instead → {ids, ruby: [{start, end, text, position, align, sizePercent, overhang}…] (the first object's ruby)}",
|
||||
has_selection,
|
||||
ruby
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -169,6 +178,67 @@ fn fractional_widths(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"on": on, "ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Type › Ruby: set or remove the ruby (furigana) over a byte range of the selected type in one
|
||||
/// undo step. Without `start`/`end` the range is all the text; a new reading replaces the ruby
|
||||
/// over the range. The reply lists the first object's ruby (start/end in bytes of its plain text).
|
||||
fn ruby(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "type.ruby";
|
||||
let ids = text_targets(s, p, C)?;
|
||||
let range = TextRange::parse(p, C)?;
|
||||
let remove = p.get("remove").and_then(Value::as_bool).unwrap_or(false);
|
||||
let reading = str_param(p, "text").map(str::trim).filter(|t| !t.is_empty()).map(str::to_string);
|
||||
if !remove && reading.is_none() {
|
||||
return Err(bad(C, "give `text` (the reading) or `remove`"));
|
||||
}
|
||||
let position = match str_param(p, "position").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("above") | Some("right") => RubyPosition::Above,
|
||||
Some("below") | Some("left") => RubyPosition::Below,
|
||||
Some(_) => return Err(bad(C, "`position` is above or below")),
|
||||
};
|
||||
let align = match str_param(p, "align").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("center") | Some("centre") => RubyAlign::Center,
|
||||
Some("start") | Some("left") | Some("top") => RubyAlign::Start,
|
||||
Some("end") | Some("right") | Some("bottom") => RubyAlign::End,
|
||||
Some(_) => return Err(bad(C, "`align` is start, center or end")),
|
||||
};
|
||||
let size = p.get("size").and_then(Value::as_f64).unwrap_or(50.0);
|
||||
if !size.is_finite() || !(1.0..=100.0).contains(&size) {
|
||||
return Err(bad(C, "`size` is a percentage of the base text's size, 1 to 100"));
|
||||
}
|
||||
let overhang = p.get("overhang").and_then(Value::as_bool).unwrap_or(true);
|
||||
let mut first: Option<Value> = None;
|
||||
s.edit("Ruby", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else {
|
||||
return Err(EngineError::NoNode(*id));
|
||||
};
|
||||
let (a, b) = match range {
|
||||
Some(r) => r.bytes(t),
|
||||
None => (0, vectorcraft_text::edit::runs_len(&t.runs)),
|
||||
};
|
||||
// The ruby over the range goes; a new reading takes its place.
|
||||
t.ruby.retain(|r| r.end <= a || r.start >= b);
|
||||
if let Some(text) = &reading
|
||||
&& a < b
|
||||
{
|
||||
let mut span = RubySpan::new(a, b, text.clone());
|
||||
span.position = position;
|
||||
span.align = align;
|
||||
span.size_percent = size;
|
||||
span.overhang = overhang;
|
||||
t.ruby.push(span);
|
||||
}
|
||||
t.normalize_ruby();
|
||||
refresh_bounds(t);
|
||||
if first.is_none() {
|
||||
first = Some(serde_json::to_value(&t.ruby).unwrap_or(Value::Null));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>(), "ruby": first.unwrap_or(Value::Null)}))
|
||||
}
|
||||
|
||||
/// Recompute the layout caches (bounds and baselines) after a text edit. Every engine text edit ends here (typing
|
||||
/// through `text.editRange` too), inside its `Session::edit`, so this is where Auto Size area type
|
||||
/// ([`vectorcraft_doc::AreaFit::AutoHeight`]) fits its frame to the text: part of the same undo
|
||||
@@ -580,6 +650,8 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Every paragraph of the new text takes the first paragraph's attributes.
|
||||
t.splice_paras(0, vectorcraft_text::edit::runs_len(&t.runs), &text);
|
||||
t.runs = vec![vectorcraft_doc::TextRun { text: text.clone(), style, inline: None }];
|
||||
// The text is new: the ruby of the old one is gone.
|
||||
t.ruby.clear();
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
@@ -1772,3 +1844,116 @@ mod headline_tests {
|
||||
assert!(avail - w < 5.0, "{w} of {avail}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ruby_tests {
|
||||
use super::*;
|
||||
|
||||
fn session_with_text() -> (Session, u64) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "漢字です", "size": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn text_of(s: &Session, id: u64) -> &TextObject {
|
||||
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("text object") };
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_command_sets_reports_removes_and_survives_undo() {
|
||||
let (mut s, id) = session_with_text();
|
||||
let r = s.execute("type.ruby", &json!({"text": "かんじ", "start": 0, "end": 6})).unwrap();
|
||||
assert_eq!(r["ruby"][0]["start"], 0);
|
||||
assert_eq!(r["ruby"][0]["end"], 6);
|
||||
assert_eq!(r["ruby"][0]["text"], "かんじ");
|
||||
let t = text_of(&s, id);
|
||||
assert_eq!(t.ruby.len(), 1);
|
||||
assert_eq!(t.ruby[0].position, RubyPosition::Above);
|
||||
assert_eq!(t.ruby[0].align, RubyAlign::Center);
|
||||
assert!(t.ruby[0].overhang);
|
||||
assert!(t.cached_bounds.unwrap().height() > 20.0, "bounds cover the ruby");
|
||||
// Options, and a new reading over the range replaces the old one.
|
||||
s.execute("type.ruby", &json!({"text": "かん", "start": 0, "end": 3, "position": "below", "align": "end", "size": 80, "overhang": false}))
|
||||
.unwrap();
|
||||
let t = text_of(&s, id);
|
||||
assert_eq!(t.ruby.len(), 1);
|
||||
assert_eq!(t.ruby[0].position, RubyPosition::Below);
|
||||
assert_eq!(t.ruby[0].align, RubyAlign::End);
|
||||
assert!((t.ruby[0].size_percent - 80.0).abs() < 1e-9 && !t.ruby[0].overhang);
|
||||
// Remove takes the ruby off the range; Undo brings it back.
|
||||
let r = s.execute("type.ruby", &json!({"remove": true, "start": 0, "end": 6})).unwrap();
|
||||
assert_eq!(r["ruby"].as_array().map(Vec::len), Some(0));
|
||||
assert!(text_of(&s, id).ruby.is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text_of(&s, id).ruby.len(), 1);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(text_of(&s, id).ruby.is_empty());
|
||||
// Bad input changes nothing.
|
||||
for p in [
|
||||
json!({"start": 0, "end": 6}),
|
||||
json!({"text": "あ", "size": 0}),
|
||||
json!({"text": "あ", "size": 101}),
|
||||
json!({"text": "あ", "position": "sideways"}),
|
||||
json!({"text": "あ", "align": "lean"}),
|
||||
] {
|
||||
assert!(s.execute("type.ruby", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(text_of(&s, id).ruby.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_text_before_a_ruby_moves_it() {
|
||||
let (mut s, id) = session_with_text();
|
||||
s.execute("type.ruby", &json!({"text": "かんじ", "start": 0, "end": 6})).unwrap();
|
||||
s.execute("text.editRange", &json!({"id": id, "start": 0, "end": 0, "insert": "あ"})).unwrap();
|
||||
let t = text_of(&s, id);
|
||||
assert_eq!((t.ruby[0].start, t.ruby[0].end), (3, 9), "the ruby follows the text");
|
||||
assert_eq!(t.plain_text(), "あ漢字です");
|
||||
// Setting all the text drops the old ruby (it isn't the same text any more).
|
||||
s.execute("text.setText", &json!({"id": id, "text": "新しい"})).unwrap();
|
||||
assert!(text_of(&s, id).ruby.is_empty());
|
||||
}
|
||||
|
||||
/// Ruby is written to SVG and PDF with its text: SVG outlines the type where ruby is present
|
||||
/// (a reader's `<text>` can't place the reading), PDF differs when the ruby is there.
|
||||
#[test]
|
||||
fn ruby_reaches_svg_and_pdf_exports() {
|
||||
let (mut s, _) = session_with_text();
|
||||
let export = |s: &mut Session, format: &str| -> Vec<u8> {
|
||||
let r = s.execute("document.export", &json!({"format": format})).unwrap();
|
||||
vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()
|
||||
};
|
||||
s.execute("type.ruby", &json!({"text": "かんじ", "start": 0, "end": 6})).unwrap();
|
||||
let svg_ruby = String::from_utf8(export(&mut s, "svg")).unwrap();
|
||||
let pdf_ruby = export(&mut s, "pdf");
|
||||
assert!(!svg_ruby.contains("<text"), "ruby type is outlined: {svg_ruby}");
|
||||
assert!(!pdf_ruby.is_empty());
|
||||
s.execute("type.ruby", &json!({"remove": true})).unwrap();
|
||||
let svg_plain = String::from_utf8(export(&mut s, "svg")).unwrap();
|
||||
let pdf_plain = export(&mut s, "pdf");
|
||||
assert!(svg_plain.contains("<text"), "without ruby the type stays live text: {svg_plain}");
|
||||
assert_ne!(svg_ruby, svg_plain, "the SVG carries the ruby");
|
||||
assert_ne!(pdf_ruby, pdf_plain, "the PDF carries the ruby");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_survives_a_native_round_trip() {
|
||||
let (mut s, id) = session_with_text();
|
||||
s.execute("type.ruby", &json!({"text": "かんじ", "start": 0, "end": 6, "position": "below", "size": 40})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let bytes = vectorcraft_format::save(&doc, false);
|
||||
let back = vectorcraft_format::load(&bytes).unwrap();
|
||||
let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
assert_eq!(t.ruby.len(), 1);
|
||||
assert_eq!((t.ruby[0].start, t.ruby[0].end), (0, 6));
|
||||
assert_eq!(t.ruby[0].text, "かんじ");
|
||||
assert_eq!(t.ruby[0].position, RubyPosition::Below);
|
||||
assert!((t.ruby[0].size_percent - 40.0).abs() < 1e-9);
|
||||
// The loaded type lays the reading out too.
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
assert!(!lay.ruby.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user