CJK 1.8: arrow keys follow vertical lines

In vertical text Down and Up move along the line by a character (by a
word with Alt), Left and Right to the next and previous line keeping the
position along it, and Shift extends, as the arrows do in horizontal
text turned a quarter. The caret is a bar across the vertical line.
This commit is contained in:
aiongg 2026-10-06 15:07:37 -04:00
parent 6df33fa344
commit e6e843dc26
3 changed files with 91 additions and 3 deletions

View File

@ -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).

View File

@ -651,8 +651,18 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
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);
}
}

View File

@ -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 |