M7.3/M7.7: threaded text (Type → Threaded Text: Create/Release Selection/Remove Threading; story re-flows after any edit touching its frames, deleted frames' text flows on), Fit Headline; roadmap
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f522e6bd3d
commit
d9df5b0135
|
|
@ -13,7 +13,7 @@ _Last updated: 2026-10-01._
|
|||
| Infrastructure (engine, command registry, history, render, formats, MCP, web, packaging, tests) | **~90%** |
|
||||
| Look & feel vs Illustrator 2026 default workspace (measured) | **~75–80%** |
|
||||
| Feature surface vs full Illustrator | **~45–50%** |
|
||||
| Estimated time to **checklist parity** (every menu item, tool, panel and dialog functional) | **~210–340 h** (sum of the milestone rows below) |
|
||||
| Estimated time to **checklist parity** (every menu item, tool, panel and dialog functional) | **~200–330 h** (sum of the milestone rows below) |
|
||||
| Estimated time to **1:1 feel** (edge cases, modifier nuances, typographic/colour fidelity) | **~500+ h** |
|
||||
|
||||
### Shipped so far
|
||||
|
|
@ -40,6 +40,7 @@ _Last updated: 2026-10-01._
|
|||
- **Geometry and effects:**
|
||||
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, and raster drop shadow, glows and feather.
|
||||
- **Type:** Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline.
|
||||
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
|
||||
- **Advanced art:** live Blends (steps/distance/smooth colour, spine), Envelope Distort (warp/mesh/top object), Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
|
||||
|
|
@ -60,7 +61,7 @@ _Last updated: 2026-10-01._
|
|||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); PNG/PDF clipboard flavours, EPS/DXF pending | 8–12 |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `drawcraft-cli bench` and `drawcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 10–20 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Shape Builder edge erase, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 in progress | 20–30 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs done; OpenType/Tabs panels, text wrap, Find Font, spell check pending | 12–20 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done; 3D pending | 25–40 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 8–15 |
|
||||
|
|
@ -70,7 +71,7 @@ _Last updated: 2026-10-01._
|
|||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces done; accessibility, Windows/Linux packaging pending | 12–20 |
|
||||
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 40–60 |
|
||||
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 30–50 |
|
||||
| | **Total to checklist parity** (sum of the rows above) | | **~210–340** |
|
||||
| | **Total to checklist parity** (sum of the rows above) | | **~200–330** |
|
||||
|
||||
## Out of scope (by design or by law)
|
||||
- **Native `.ai` private data:** it's undocumented. We read the PDF-compatible part, so Illustrator-only live objects arrive as appearance.
|
||||
|
|
|
|||
|
|
@ -274,6 +274,9 @@ pub struct Document {
|
|||
/// Paragraph styles (besides the built-in [Normal Paragraph Style]).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub para_styles: Vec<TextStyleDef>,
|
||||
/// Threaded text: area-type frames one story flows through, in order.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub text_threads: Vec<Vec<NodeId>>,
|
||||
#[serde(default)]
|
||||
pub symbols: Vec<Symbol>,
|
||||
#[serde(default)]
|
||||
|
|
@ -324,6 +327,7 @@ impl Document {
|
|||
graphic_styles: default_graphic_styles(),
|
||||
char_styles: vec![],
|
||||
para_styles: vec![],
|
||||
text_threads: vec![],
|
||||
symbols: vec![],
|
||||
guides: vec![],
|
||||
grid: GridPrefs::default(),
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ mod recolor;
|
|||
mod select;
|
||||
mod textedit;
|
||||
pub mod textstyles;
|
||||
pub(crate) mod threads;
|
||||
mod typecmd;
|
||||
mod typemenu;
|
||||
pub mod wand;
|
||||
|
|
@ -153,6 +154,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(typemenu::specs());
|
||||
v.extend(textedit::specs());
|
||||
v.extend(textstyles::specs());
|
||||
v.extend(threads::specs());
|
||||
v.extend(docmenu::specs());
|
||||
v.extend(docinfo::specs());
|
||||
v.extend(panelcmds::specs());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,281 @@
|
|||
//! Type → Threaded Text: one story flowing through area-type frames in order.
|
||||
//!
|
||||
//! Frames are ordinary area text objects holding their slice of the story; the document lists the
|
||||
//! thread order. After every edit, threads whose frames changed are re-flowed ([`reflow`]).
|
||||
|
||||
use drawcraft_doc::{Document, Node, NodeId, NodeKind, TextKind, TextObject, TextRun};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::typecmd::refresh_bounds;
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"text.thread.create",
|
||||
"Create",
|
||||
["Type", "Threaded Text"],
|
||||
None,
|
||||
"{} thread the selected area type (and closed paths, which become frames) in stacking order → {ids}",
|
||||
has_multi,
|
||||
create
|
||||
),
|
||||
cmd!(
|
||||
"text.thread.releaseSelection",
|
||||
"Release Selection",
|
||||
["Type", "Threaded Text"],
|
||||
None,
|
||||
"{} take the selected frames out of their threads (the story re-flows through the rest)",
|
||||
has_thread_selection,
|
||||
release
|
||||
),
|
||||
cmd!(
|
||||
"text.thread.remove",
|
||||
"Remove Threading",
|
||||
["Type", "Threaded Text"],
|
||||
None,
|
||||
"{} break the selected frames' threads; each frame keeps the text it shows",
|
||||
has_thread_selection,
|
||||
remove
|
||||
),
|
||||
cmd!(query "text.thread.info", "Threads", [], None, "{} → [[id…]] every thread in order", has_doc, |s, _| {
|
||||
Ok(json!(s.doc()?.doc.text_threads.iter().map(|t| t.iter().map(|i| i.0).collect::<Vec<_>>()).collect::<Vec<_>>()))
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
fn has_thread_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
let st = s.active().unwrap();
|
||||
if st.selection.objects.iter().any(|id| st.doc.text_threads.iter().any(|t| t.contains(id))) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("no threaded text selected".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn text_of(d: &Document, id: NodeId) -> Option<&TextObject> {
|
||||
match d.node(id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::Area { .. }) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-flow every thread with a frame that changed between `before` and `doc` (structural sharing
|
||||
/// makes "changed" a pointer comparison). Deleted frames' text flows into the remaining ones.
|
||||
pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
let threads = std::mem::take(&mut doc.text_threads);
|
||||
let mut kept = vec![];
|
||||
for thread in threads {
|
||||
let changed = thread.iter().any(|id| match (before.node(*id), doc.node(*id)) {
|
||||
(Some(a), Some(b)) => !std::ptr::eq(a, b),
|
||||
(None, None) => false,
|
||||
_ => true,
|
||||
});
|
||||
// The story: live frames' runs, plus the text of frames deleted by this edit.
|
||||
let mut story: Vec<TextRun> = vec![];
|
||||
let mut live = vec![];
|
||||
for id in &thread {
|
||||
match (text_of(doc, *id), text_of(before, *id)) {
|
||||
(Some(t), _) => {
|
||||
story.extend(t.runs.iter().cloned());
|
||||
live.push(*id);
|
||||
}
|
||||
(None, Some(old)) => story.extend(old.runs.iter().cloned()),
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
if !changed || live.is_empty() {
|
||||
if live.len() >= 2 {
|
||||
kept.push(live);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
drawcraft_text::edit::normalize(&mut story);
|
||||
let frames: Vec<TextObject> = live.iter().filter_map(|id| text_of(doc, *id).cloned()).collect();
|
||||
let refs: Vec<&TextObject> = frames.iter().collect();
|
||||
let parts = drawcraft_text::thread::distribute(drawcraft_text::FontDb::global(), &refs, &story);
|
||||
for (id, runs) in live.iter().zip(parts) {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind)
|
||||
&& t.runs != runs
|
||||
{
|
||||
t.runs = runs;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
if live.len() >= 2 {
|
||||
kept.push(live);
|
||||
}
|
||||
}
|
||||
doc.text_threads = kept;
|
||||
}
|
||||
|
||||
/// A closed path converted to an empty area-type frame with `style`.
|
||||
fn frame_from_path(n: &Node, style: drawcraft_doc::CharStyle) -> Option<Node> {
|
||||
let NodeKind::Path { path, .. } = &n.kind else { return None };
|
||||
if !path.subpaths.first().is_some_and(|s| s.closed) {
|
||||
return None;
|
||||
}
|
||||
let mut t = TextObject::point(drawcraft_geom::Point::ZERO, "", style);
|
||||
t.kind = TextKind::Area { frame: path.clone() };
|
||||
t.xf = drawcraft_geom::Affine::IDENTITY;
|
||||
refresh_bounds(&mut t);
|
||||
Some(Node::new(n.id, NodeKind::Text(Box::new(t))))
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
const C: &str = "text.thread.create";
|
||||
let ids = selected_roots(s)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let style = ids.iter().find_map(|i| text_of(d, *i)).map(|t| t.first_style()).unwrap_or_default();
|
||||
let ok = ids.iter().all(|i| text_of(d, *i).is_some() || d.node(*i).is_some_and(|n| frame_from_path(n, style.clone()).is_some()));
|
||||
if ids.len() < 2 || !ok || !ids.iter().any(|i| text_of(d, *i).is_some()) {
|
||||
return Err(bad(C, "select area type and closed paths (at least one area type object)"));
|
||||
}
|
||||
let out = ids.clone();
|
||||
s.edit("Thread Text", |d, _| {
|
||||
for id in &ids {
|
||||
if text_of(d, *id).is_none() {
|
||||
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
||||
let f = frame_from_path(&n, style.clone()).ok_or_else(|| bad(C, "not a closed path"))?;
|
||||
*d.node_mut(*id).ok_or(EngineError::NoNode(*id))? = f;
|
||||
}
|
||||
}
|
||||
// Existing threads containing any of these frames merge into the new one, keeping order.
|
||||
let mut order: Vec<NodeId> = vec![];
|
||||
for id in &ids {
|
||||
match d.text_threads.iter().position(|t| t.contains(id)) {
|
||||
Some(i) => {
|
||||
for x in d.text_threads.remove(i) {
|
||||
if !order.contains(&x) {
|
||||
order.push(x);
|
||||
}
|
||||
}
|
||||
}
|
||||
None if !order.contains(id) => order.push(*id),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
d.text_threads.push(order);
|
||||
// Force a re-flow of the new thread on this edit.
|
||||
touch(d, ids[0]);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "ids": out.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Give a node a fresh allocation so [`reflow`] sees its thread as changed.
|
||||
fn touch(d: &mut Document, id: NodeId) {
|
||||
if let Some(n) = d.node_mut(id) {
|
||||
let copy = n.clone();
|
||||
*n = copy;
|
||||
}
|
||||
}
|
||||
|
||||
fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
s.edit("Release Threaded Text", |d, _| {
|
||||
// The released frames' text joins the rest of the story (they become empty).
|
||||
for thread in d.text_threads.clone() {
|
||||
let (out, keep): (Vec<NodeId>, Vec<NodeId>) = thread.iter().partition(|i| ids.contains(i));
|
||||
if out.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut story: Vec<TextRun> = vec![];
|
||||
for id in &thread {
|
||||
if let Some(t) = text_of(d, *id) {
|
||||
story.extend(t.runs.iter().cloned());
|
||||
}
|
||||
}
|
||||
let style = story.first().map(|r| r.style.clone()).unwrap_or_default();
|
||||
for id in &out {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
t.runs = vec![TextRun { text: String::new(), style: style.clone() }];
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
if let Some(first) = keep.first()
|
||||
&& let Some(NodeKind::Text(t)) = d.node_mut(*first).map(|n| &mut n.kind)
|
||||
{
|
||||
drawcraft_text::edit::normalize(&mut story);
|
||||
t.runs = story;
|
||||
}
|
||||
let i = d.text_threads.iter().position(|t| *t == thread).unwrap_or(0);
|
||||
d.text_threads[i] = keep;
|
||||
}
|
||||
d.text_threads.retain(|t| t.len() >= 2);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
s.edit("Remove Threading", |d, _| {
|
||||
d.text_threads.retain(|t| !t.iter().any(|i| ids.contains(i)));
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TEXT: &str = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame of this story. ";
|
||||
|
||||
fn setup() -> (Session, u64, u64) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": TEXT.repeat(4), "area": {"width": 150, "height": 60}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
(s, a, b)
|
||||
}
|
||||
|
||||
fn text(s: &Session, id: u64) -> String {
|
||||
match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.plain_text(),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_flows_reflows_on_edit_and_survives_delete() {
|
||||
let (mut s, a, b) = setup();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
let (ta, tb) = (text(&s, a), text(&s, b));
|
||||
assert!(!ta.is_empty() && !tb.is_empty());
|
||||
assert_eq!(format!("{ta}{tb}"), TEXT.repeat(4));
|
||||
// Editing the first frame re-flows into the second.
|
||||
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap();
|
||||
assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), format!("NEW {}", TEXT.repeat(4)));
|
||||
assert_ne!(text(&s, b), tb, "the overflow moved on");
|
||||
// Deleting the first frame moves all its text into the second.
|
||||
s.execute("select.set", &json!({"ids": [a]})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, b), format!("NEW {}", TEXT.repeat(4)));
|
||||
assert!(s.doc().unwrap().doc.text_threads.is_empty(), "a one-frame thread is no thread");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.text_threads.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_and_remove() {
|
||||
let (mut s, a, b) = setup();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
let tb = text(&s, b);
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
s.execute("text.thread.remove", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, b), tb, "remove keeps what each frame shows");
|
||||
assert!(s.doc().unwrap().doc.text_threads.is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, b), "");
|
||||
assert_eq!(text(&s, a), TEXT.repeat(4), "the story returns to the remaining frame");
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_selection,
|
||||
area_options
|
||||
),
|
||||
cmd!(
|
||||
"text.fitHeadline",
|
||||
"Fit Headline",
|
||||
["Type"],
|
||||
None,
|
||||
"{ids?} track the first line of area type so it fills the frame width → {tracking}",
|
||||
has_selection,
|
||||
fit_headline
|
||||
),
|
||||
cmd!(
|
||||
"text.setStyle",
|
||||
"Character",
|
||||
|
|
@ -295,3 +304,75 @@ mod area_tests {
|
|||
assert!(s.execute("text.areaOptions", &json!({"firstBaseline": "nope"})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of the first line and of the space it can fill, with `tracking` on the first paragraph.
|
||||
fn headline_fit(t: &TextObject, tracking: f64) -> Option<(f64, f64, usize)> {
|
||||
let mut probe = t.clone();
|
||||
let para_end = probe.plain_text().find('\n').unwrap_or(usize::MAX);
|
||||
drawcraft_text::edit::style_range(&mut probe.runs, 0, para_end.min(drawcraft_text::edit::runs_len(&t.runs)), |st| st.tracking = tracking);
|
||||
let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), &probe);
|
||||
let cell = lay.frames.first()?;
|
||||
let line = lay.lines.first()?;
|
||||
let avail = cell.width() - 2.0 * t.area.inset - t.para.left_indent - t.para.right_indent;
|
||||
Some((line.x1 - line.x0, avail, lay.lines.iter().filter(|l| l.start < para_end).count()))
|
||||
}
|
||||
|
||||
fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.fitHeadline";
|
||||
let ids = text_targets(s, p, C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let mut plans = vec![];
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node(*id).map(|n| &n.kind) else { continue };
|
||||
if !matches!(t.kind, drawcraft_doc::TextKind::Area { .. }) {
|
||||
continue;
|
||||
}
|
||||
// Largest tracking that keeps the first paragraph on one line (bisection).
|
||||
let one_line = |tr: f64| headline_fit(t, tr).is_some_and(|(_, _, lines)| lines == 1);
|
||||
let (mut lo, mut hi) = (-200.0, 2000.0);
|
||||
if !one_line(lo) {
|
||||
continue;
|
||||
}
|
||||
for _ in 0..24 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if one_line(mid) { lo = mid } else { hi = mid }
|
||||
}
|
||||
plans.push((*id, (lo * 10.0).floor() / 10.0));
|
||||
}
|
||||
let first = plans.first().map(|p| p.1).ok_or_else(|| bad(C, "select area type whose first line can fit its frame"))?;
|
||||
s.edit("Fit Headline", |d, _| {
|
||||
for (id, tr) in &plans {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
let end = t.plain_text().find('\n').unwrap_or(usize::MAX).min(drawcraft_text::edit::runs_len(&t.runs));
|
||||
drawcraft_text::edit::style_range(&mut t.runs, 0, end, |st| st.tracking = *tr);
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "tracking": first }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod headline_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fit_headline_fills_the_first_line() {
|
||||
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": "HEADLINE\nbody text", "size": 24, "area": {"width": 300, "height": 120}}))
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let tr = s.execute("text.fitHeadline", &json!({})).unwrap()["tracking"].as_f64().unwrap();
|
||||
assert!(tr > 100.0, "a short word spreads out: {tr}");
|
||||
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!() };
|
||||
let (w, avail, lines) = headline_fit(t, tr).unwrap();
|
||||
assert_eq!(lines, 1);
|
||||
assert!(avail - w < 30.0, "{w} of {avail}");
|
||||
assert_eq!(t.runs.last().unwrap().style.tracking, 0.0, "the body keeps its tracking");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -547,13 +547,20 @@ impl Session {
|
|||
let before = st.doc.clone();
|
||||
let before_sel = st.selection.clone();
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
let result = f(doc, &mut st.selection).and_then(|v| {
|
||||
if doc_sane(&st.doc, &st.selection) {
|
||||
Ok(v)
|
||||
} else {
|
||||
Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()))
|
||||
let result = match f(doc, &mut st.selection) {
|
||||
Ok(v) => {
|
||||
// Threaded text re-flows when any of its frames changed.
|
||||
if !st.doc.text_threads.is_empty() {
|
||||
cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc));
|
||||
}
|
||||
if doc_sane(&st.doc, &st.selection) {
|
||||
Ok(v)
|
||||
} else {
|
||||
Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()))
|
||||
}
|
||||
}
|
||||
});
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
match result {
|
||||
Ok(v) => {
|
||||
st.selection.prune(&st.doc);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ mod fontdb;
|
|||
pub mod hyphen;
|
||||
mod layout;
|
||||
mod shape;
|
||||
pub mod thread;
|
||||
|
||||
pub use drawcraft_doc::TextObject;
|
||||
pub use features::OtFeatures;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
//! Threaded text: one story flowing through several area-type frames in order.
|
||||
//!
|
||||
//! Each frame keeps its own slice of the story as ordinary runs, so rendering, export and hit
|
||||
//! testing need nothing special; after an edit the engine joins the slices and re-distributes them
|
||||
//! with [`distribute`].
|
||||
|
||||
use drawcraft_doc::{TextObject, TextRun};
|
||||
|
||||
use crate::edit::{normalize, runs_len, slice_runs, style_at};
|
||||
use crate::{FontDb, layout};
|
||||
|
||||
/// The story: every frame's runs joined in thread order.
|
||||
pub fn story(frames: &[&TextObject]) -> Vec<TextRun> {
|
||||
let mut runs: Vec<TextRun> = frames.iter().flat_map(|f| f.runs.iter().cloned()).collect();
|
||||
normalize(&mut runs);
|
||||
runs
|
||||
}
|
||||
|
||||
/// How many bytes of `runs` fit in `frame` (whole lines; a following break space or paragraph
|
||||
/// break stays with the line that ends there).
|
||||
fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize {
|
||||
let mut probe = frame.clone();
|
||||
probe.runs = runs.to_vec();
|
||||
let lay = layout(db, &probe);
|
||||
let text: String = runs.iter().map(|r| r.text.as_str()).collect();
|
||||
if !lay.overflow {
|
||||
return text.len();
|
||||
}
|
||||
let mut end = lay.lines.last().map_or(0, |l| l.end);
|
||||
let bytes = text.as_bytes();
|
||||
while end < bytes.len() && bytes[end] == b' ' {
|
||||
end += 1;
|
||||
}
|
||||
if end < bytes.len() && bytes[end] == b'\n' {
|
||||
end += 1;
|
||||
}
|
||||
end
|
||||
}
|
||||
|
||||
/// The story `runs` split across `frames` in order (the last frame takes any overflow). Empty
|
||||
/// slices keep one empty run carrying the style at the split, so typing there has a style.
|
||||
pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun]) -> Vec<Vec<TextRun>> {
|
||||
let mut rest = runs.to_vec();
|
||||
let mut out = Vec::with_capacity(frames.len());
|
||||
for (i, f) in frames.iter().enumerate() {
|
||||
let len = runs_len(&rest);
|
||||
let n = if i + 1 == frames.len() { len } else { fit(db, f, &rest) };
|
||||
let mut head = slice_runs(&rest, 0, n);
|
||||
if head.is_empty() {
|
||||
head.push(TextRun { text: String::new(), style: style_at(&rest, n) });
|
||||
}
|
||||
let tail = slice_runs(&rest, n, len);
|
||||
rest = if tail.is_empty() { vec![TextRun { text: String::new(), style: style_at(&rest, len) }] } else { tail };
|
||||
out.push(head);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use drawcraft_doc::{CharStyle, TextKind};
|
||||
use drawcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
fn frame(x: f64) -> TextObject {
|
||||
let mut t = TextObject::point(drawcraft_geom::Point::ZERO, "", CharStyle::default());
|
||||
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(x, 0.0, x + 120.0, 60.0)) };
|
||||
t.xf = Affine::IDENTITY;
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn story_flows_across_frames_and_rejoins() {
|
||||
let db = FontDb::global();
|
||||
let (a, b, c) = (frame(0.0), frame(200.0), frame(400.0));
|
||||
let text = "The quick brown fox jumps over the lazy dog. ".repeat(6) + "\nSecond paragraph here.";
|
||||
let story = vec![TextRun { text: text.clone(), style: CharStyle::default() }];
|
||||
let parts = distribute(db, &[&a, &b, &c], &story);
|
||||
let lens: Vec<usize> = parts.iter().map(|p| runs_len(p)).collect();
|
||||
assert!(lens[0] > 0 && lens[1] > 0, "{lens:?}");
|
||||
let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect();
|
||||
assert_eq!(joined, text, "no character lost or duplicated");
|
||||
// Each non-last slice fits its frame.
|
||||
for (f, p) in [&a, &b].iter().zip(&parts) {
|
||||
let mut t = (*f).clone();
|
||||
t.runs = p.clone();
|
||||
assert!(!layout(db, &t).overflow);
|
||||
}
|
||||
// Lines never start with the break space.
|
||||
assert!(!parts[1][0].text.starts_with(' '));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_story_leaves_later_frames_empty_with_a_style() {
|
||||
let db = FontDb::global();
|
||||
let (a, b) = (frame(0.0), frame(200.0));
|
||||
let st = CharStyle { size: 20.0, ..Default::default() };
|
||||
let parts = distribute(db, &[&a, &b], &[TextRun { text: "Hi".into(), style: st }]);
|
||||
assert_eq!(parts[0][0].text, "Hi");
|
||||
assert_eq!((parts[1][0].text.as_str(), parts[1][0].style.size), ("", 20.0));
|
||||
}
|
||||
}
|
||||
|
|
@ -818,8 +818,15 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
cp("Type on a Path Options…", "type.pathOptions", json!({"start": 0})),
|
||||
],
|
||||
),
|
||||
sub("Threaded Text", vec![todo("Create"), todo("Release Selection"), todo("Remove Threading")]),
|
||||
todo("Fit Headline"),
|
||||
sub(
|
||||
"Threaded Text",
|
||||
vec![
|
||||
c("Create", "text.thread.create"),
|
||||
c("Release Selection", "text.thread.releaseSelection"),
|
||||
c("Remove Threading", "text.thread.remove"),
|
||||
],
|
||||
),
|
||||
c("Fit Headline", "text.fitHeadline"),
|
||||
Sep,
|
||||
todo("Find Font…"),
|
||||
sub(
|
||||
|
|
|
|||
Loading…
Reference in New Issue