diff --git a/ROADMAP.md b/ROADMAP.md index c7973d3..9975367 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,7 +111,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Keyboard: Tab / Shift+Tab hide and show all panels / all but Tools (`window.hidePanels`, `window.hidePanelsExceptTools`); Cmd+F6 / Cmd+Shift+F6 cycle documents; panel fields are reached with Tab and Escape returns to the layout. - Smart guides and snapping (View ▸ Snap to Guides / Snap to Document Grid / Smart Guides, `view.snapPreferences` and Preferences ▸ Guides & Pasteboard): moving, drawing, resizing, rotating and pen/anchor drags snap in order to the document and baseline grids, ruler guides, margins and columns, other objects' edges and centres (parent-page items too, by visible stroked bounds), equal spacing, matching dimensions and matching rotation, with guides, gap marks and measurements drawn on the canvas. Shift and Cmd constrain the gesture. - View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread. -- Vertical type (Vertical Type Tool, Type ▸ Story Direction `type.storyDirection`, IDML StoryOrientation): the direction belongs to the story, so every frame of a thread is vertical or horizontal together and a frame threaded onto a story takes its direction (`object.textFrameOptions {vertical}` sets the frames' stories); lines run top to bottom and follow each other right to left; CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet. +- Vertical type (Vertical Type Tool, Type ▸ Story Direction `type.storyDirection`, IDML StoryOrientation): the direction belongs to the story, so every frame of a thread is vertical or horizontal together and a frame threaded onto a story takes its direction (`object.textFrameOptions {vertical}` sets the frames' stories); lines run top to bottom and follow each other right to left; the arrow keys follow them (Down and Up along the line, Left and Right to the next and previous line, keeping the position along it); CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet. - Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited. - Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers. - Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted). diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 2885704..d81e169 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -651,8 +651,18 @@ fn move_caret(s: &mut Session, p: &Value) -> Result { None => cs, }; 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.as_str() { + let new = match dir { "left" if !extend && !t.is_caret() => collapse_to(true), "right" if !extend && !t.is_caret() => collapse_to(false), "left" | "right" => { @@ -1493,3 +1503,81 @@ mod story_direction_tests { 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); + } +} diff --git a/docs/cjk.md b/docs/cjk.md index fb7108b..85cbbe2 100644 --- a/docs/cjk.md +++ b/docs/cjk.md @@ -31,7 +31,7 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built | Area | State | |---|---| -| Vertical text, upright CJK, rotated Latin | done: story direction per story (frames of a thread agree; IDML `StoryOrientation`); glyphs turned by transforms | +| Vertical text, upright CJK, rotated Latin | done: story direction per story (frames of a thread agree; IDML `StoryOrientation`); glyphs turned by transforms; arrow keys follow the vertical lines | | Vertical metrics and shaping | done: upright runs shaped top to bottom (`vert`; `vkrn`/`vpal` when asked) with `vmtx`/`VORG` advances and origins, centred on the ideographic em box (`BASE`, else OS/2); turned runs shaped horizontally, without `vrt2`; Horizontal Kana (`hkna`) takes the vertical kana (`vkna`) in upright runs. Default location of variable fonts only (no `VVAR`) | | Kinsoku | named (hard, soft, Chinese, Korean) and custom kinsoku sets, bunri-kinshi, rensuuji, hanging punctuation (regular, force); without a set, fixed no-start / no-end lists and a break anywhere between CJK characters; kinsoku type (push in / push out priorities) kept but not applied | | Korean line breaking | at spaces (Hangul everywhere, hanja in Korean text); per-paragraph character-based breaking (`koreanCharBreaks`); DesignCraft-only, not in IDML |