diff --git a/ROADMAP.md b/ROADMAP.md index 6f60cf4..c7973d3 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, `object.textFrameOptions {vertical}`, IDML StoryOrientation): 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; 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/compose/src/lib.rs b/crates/compose/src/lib.rs index 43eb9e1..8f7685f 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -277,6 +277,9 @@ pub struct FrameSpec { /// Text area (inner space, after inset). pub area: Rect, pub opts: TextFrameOptions, + /// Lines run top to bottom and follow each other right to left (the story is vertical); the + /// area is the turned box. + pub vertical: bool, pub exclusions: Vec, pub page_name: Option, /// Absolute document page the frame is on (None on parent pages). @@ -398,7 +401,8 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { None => (item.text_area(), tf.options.clone()), }; // Vertical type: composed in the turned box; wraps and page rects turned with it. - let (area, exclusions, grid, page_rect) = if tf.options.vertical && tf.options.path.is_none() { + let vertical = doc.frame_vertical(item); + let (area, exclusions, grid, page_rect) = if vertical { let v = designcraft_doc::vertical_text_xf(area).inverse(); let ex = exclusions.into_iter().map(|e| Exclusion { rect: v.transform_rect_bbox(e.rect), ..e }).collect(); let pr = page_rect.map(|(a, b)| (v.transform_rect_bbox(a), v.transform_rect_bbox(b))); @@ -410,6 +414,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { id: fid, area, opts, + vertical, exclusions: if tf.options.path.is_some() { vec![] } else { exclusions }, page_name, page, @@ -445,7 +450,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C .iter() .map(|f| { let mut columns = f.columns(); - if story.direction == designcraft_doc::TextDirection::RightToLeft && !f.opts.vertical { + if story.direction == designcraft_doc::TextDirection::RightToLeft && !f.vertical { columns.reverse(); } columns @@ -454,7 +459,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C out.frames = frames .iter() .zip(&cols) - .map(|(f, columns)| FrameText { frame: f.id, vertical: f.opts.vertical, columns: columns.clone(), ..Default::default() }) + .map(|(f, columns)| FrameText { frame: f.id, vertical: f.vertical, columns: columns.clone(), ..Default::default() }) .collect(); let mut cur = Cursor { fi: 0, col: 0, last_baseline: None, last_descent: 0.0, last_reference: 0.0, pending: 0.0 }; let para_ranges = story.para_ranges(); @@ -547,7 +552,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C section_marker: None, vars: var_values, hidden_conditions: doc.conditions.iter().filter(|c| !c.visible).map(|c| c.name.clone()).collect(), - vertical: cur_frame.is_some_and(|f| f.opts.vertical), + vertical: cur_frame.is_some_and(|f| f.vertical), ..Default::default() }; if !story.endnotes.is_empty() { diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index d806b54..32b2bbe 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -99,6 +99,7 @@ impl Notes { id: ItemId(0), area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6), opts: TextFrameOptions { first_baseline: o.first_baseline, first_baseline_min: o.first_baseline_min, ..Default::default() }, + vertical: false, exclusions: vec![], page_name: f.page_name.clone(), page: f.page, diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index 1294933..05d97a2 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -93,6 +93,7 @@ fn compose_cell(doc: &Document, story: &Story, w: f64, opts: &ComposeOptions, le id: ItemId(0), area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6), opts: TextFrameOptions::default(), + vertical: false, exclusions: vec![], page_name: opts.page_name.clone(), page: opts.page, diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index faa3708..07ea456 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -972,15 +972,13 @@ fn ruby_and_kenten_sit_over_their_text() { fn tate_chu_yoko_sets_digits_across_one_em() { let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); let lid = d.default_layer(); - let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 400.0), lid, "令和12年", ParaFormat::default()).unwrap(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 400.0), lid, "令和12年", ParaFormat::default()).unwrap(); let at = "令和".len(); d.story_mut(sid).unwrap().format_chars(at..at + 2, |f| f.over.tate_chu_yoko = Some(true)); let glyphs = |d: &Document| compose_story(d, sid, &ComposeOptions::default()).frames[0].lines[0].glyphs.clone(); // Horizontal text ignores it. assert!(glyphs(&d).iter().all(|g| g.tcy.is_none())); - if let Some(tf) = d.item_mut(fid).and_then(|i| i.text_frame_mut()) { - tf.options.vertical = true; - } + d.story_mut(sid).unwrap().vertical = true; let gs = glyphs(&d); let digits: Vec<_> = gs.iter().filter(|g| g.tcy.is_some()).collect(); assert_eq!(digits.len(), 2); @@ -1017,9 +1015,9 @@ fn vertical_frames_compose_in_the_turned_box() { let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 400.0), lid, "縦書きの文章です。Latin", ParaFormat::default()).unwrap(); if let Some(tf) = d.item_mut(fid).and_then(|i| i.text_frame_mut()) { - tf.options.vertical = true; tf.options.inset = [0.0; 4]; } + d.story_mut(sid).unwrap().vertical = true; let cs = compose_story(&d, sid, &ComposeOptions::default()); let ft = &cs.frames[0]; assert!(ft.vertical); @@ -1030,9 +1028,9 @@ fn vertical_frames_compose_in_the_turned_box() { assert!(l.glyphs.iter().filter(|g| g.len > 0).take(5).all(|g| g.upright)); assert!(!l.glyphs.iter().rev().find(|g| g.len > 0).unwrap().upright, "Latin turns"); // Text space → frame: the first line sits at the right edge and runs down. - let it = d.item(fid).unwrap(); - let p0 = it.text_xf() * designcraft_geom::Point::new(l.glyphs[0].x, l.baseline); - let p1 = it.text_xf() * designcraft_geom::Point::new(l.glyphs[3].x, l.baseline); + let xf = d.text_xf(d.item(fid).unwrap()); + let p0 = xf * designcraft_geom::Point::new(l.glyphs[0].x, l.baseline); + let p1 = xf * designcraft_geom::Point::new(l.glyphs[3].x, l.baseline); assert!(p0.x > 150.0 && p1.y > p0.y && (p1.x - p0.x).abs() < 1e-6, "{p0:?} {p1:?}"); } @@ -1643,9 +1641,9 @@ fn vertical_line(family: &str, text: &str, over: impl Fn(&mut designcraft_doc::C let lid = d.default_layer(); let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 500.0), lid, text, ParaFormat::default()).unwrap(); if let Some(tf) = d.item_mut(fid).and_then(|i| i.text_frame_mut()) { - tf.options.vertical = true; tf.options.inset = [0.0; 4]; } + d.story_mut(sid).unwrap().vertical = true; d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| { f.over.font_family = Some(family.into()); f.over.size = Some(20.0); diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 8e4e662..188f514 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -2,7 +2,7 @@ use std::sync::Arc; -use designcraft_geom::{Point, Rect, shapes}; +use designcraft_geom::{Affine, Point, Rect, shapes}; use serde::{Deserialize, Serialize}; use crate::ids::{ItemId, LayerId, StoryId}; @@ -252,6 +252,8 @@ impl Document { let nid = StoryId(self.alloc()); let mut ns = Story::new(nid); ns.frames = before.clone(); + ns.vertical = other.vertical; + ns.direction = other.direction; for f in &before { if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) { t.story = nid; @@ -276,12 +278,12 @@ impl Document { /// stays with the first part and may become overset). pub fn unthread_after(&mut self, frame: ItemId) -> Result<()> { let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?; - let tail = { + let (tail, vertical, direction) = { let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?; let p = st.frames.iter().position(|f| *f == frame).ok_or(DocError::NoItem(frame))?; let tail = st.frames.split_off(p + 1); st.rev += 1; - tail + (tail, st.vertical, st.direction) }; if tail.is_empty() { return Ok(()); @@ -289,6 +291,8 @@ impl Document { let nid = StoryId(self.alloc()); let mut ns = Story::new(nid); ns.frames = tail.clone(); + ns.vertical = vertical; + ns.direction = direction; self.stories.insert(nid, Arc::new(ns)); for f in tail { if let Some(t) = self.item_mut(f).and_then(Item::text_frame_mut) { @@ -310,6 +314,24 @@ impl Document { p.checked_sub(1).map(|i| st.frames[i]) } + /// Does this text frame set its text vertically (its story is vertical; type on a path stays + /// along the path)? + pub fn frame_vertical(&self, item: &Item) -> bool { + item.text_frame().is_some_and(|t| t.options.path.is_none() && self.story(t.story).is_some_and(|s| s.vertical)) + } + + /// Text space → item inner space for a frame's composed text (identity except for vertical + /// frames). + pub fn text_local(&self, item: &Item) -> Affine { + if self.frame_vertical(item) { crate::item::vertical_text_xf(item.text_area()) } else { Affine::IDENTITY } + } + + /// Text space → spread-parent space for a frame's composed text (the item transform, with the + /// quarter turn of a vertical frame). + pub fn text_xf(&self, item: &Item) -> Affine { + item.xf * self.text_local(item) + } + /// Hit test: the frontmost visible, unlocked-layer item on spread `si` containing `p` (spread space). /// Frames hit by area if they have a fill or are text/graphic frames; otherwise near the outline. pub fn hit_item(&self, si: usize, p: Point, tol: f64) -> Option { @@ -348,6 +370,7 @@ impl Document { "direction": st.direction, "paragraphs": st.paras.len(), "frames": st.frames.iter().map(|f| f.0).collect::>(), + "vertical": st.vertical, "text": st.text, })) } diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 8a20ec7..5492196 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -332,9 +332,6 @@ pub struct TextFrameOptions { /// Type on a Path: the text runs along the item's path instead of filling it. #[serde(skip_serializing_if = "Option::is_none")] pub path: Option, - /// Vertical Type: lines run top to bottom and follow each other right to left. - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub vertical: bool, } /// Type on a Path Options. @@ -380,7 +377,6 @@ impl Default for TextFrameOptions { column_rule_color: designcraft_color::swatch::BLACK.into(), baseline_grid: None, path: None, - vertical: false, } } } @@ -1146,23 +1142,6 @@ impl Item { } /// The frame's text area (inner space) after inset. - /// Text space → item inner space (identity except for vertical frames). - pub fn text_local(&self) -> Affine { - match self.text_frame() { - Some(t) if t.options.vertical && t.options.path.is_none() => vertical_text_xf(self.text_area()), - _ => Affine::IDENTITY, - } - } - - /// Text space → spread-parent space for this frame's composed text (the item transform, with - /// the quarter turn of a vertical frame). - pub fn text_xf(&self) -> Affine { - match self.text_frame() { - Some(t) if t.options.vertical && t.options.path.is_none() => self.xf * vertical_text_xf(self.text_area()), - _ => self.xf, - } - } - pub fn text_area(&self) -> Rect { let r = self.inner_bounds(); let inset = self.text_frame().map(|t| t.options.inset).unwrap_or([0.0; 4]); diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index 398cf99..21ac81b 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -95,6 +95,10 @@ pub struct Story { /// Column progression, independent of paragraph bidi direction and frame threading. #[serde(default)] pub direction: crate::TextDirection, + /// Story direction: vertical stories set their lines top to bottom, following each other + /// right to left, in every frame of the thread. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub vertical: bool, /// Bumped on every edit (composition cache key). #[serde(default)] pub rev: u64, @@ -136,6 +140,7 @@ impl Story { chars: vec![CharRun { len: 0, format: CharFormat::default() }], frames: vec![], direction: crate::TextDirection::LeftToRight, + vertical: false, rev: 0, tables: BTreeMap::new(), notes: Vec::new(), diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index c3ba44d..9ee28a1 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -42,6 +42,58 @@ fn thread_to_empty_frame_converts_it() { assert!(d.thread(a, a).is_err()); } +#[test] +fn frames_keep_their_story_direction_through_threading() { + let mut d = doc(); + let lid = d.default_layer(); + let r = |x: f64| Rect::new(x, 10.0, x + 50.0, 100.0); + let vertical = |d: &Document, f: ItemId| d.item(f).is_some_and(|i| d.frame_vertical(i)); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), r(0.0), lid, "縦", ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), r(60.0), lid, "", ParaFormat::default()).unwrap(); + d.story_mut(sa).unwrap().vertical = true; + // A horizontal frame threaded onto a vertical story turns vertical. + d.thread(a, b).unwrap(); + assert!(vertical(&d, a) && vertical(&d, b)); + // Threading the vertical story's second frame after a horizontal frame: it turns horizontal, + // and the frame left before it stays vertical. + let (h, _) = d.add_text_frame(SpreadRef::Doc(0), r(120.0), lid, "横", ParaFormat::default()).unwrap(); + d.thread(h, b).unwrap(); + assert!(vertical(&d, a) && !vertical(&d, b) && !vertical(&d, h)); + // Breaking a vertical thread leaves the frames after the break vertical. + let (c, _) = d.add_text_frame(SpreadRef::Doc(0), r(180.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(a, c).unwrap(); + d.unthread_after(a).unwrap(); + assert!(vertical(&d, a) && vertical(&d, c)); + assert_ne!(d.item(c).unwrap().text_frame().unwrap().story, sa); + d.check().unwrap(); +} + +#[test] +fn frames_keep_their_column_direction_through_threading() { + use crate::TextDirection::{LeftToRight, RightToLeft}; + let mut d = doc(); + let lid = d.default_layer(); + let r = |x: f64| Rect::new(x, 10.0, x + 50.0, 100.0); + let direction = |d: &Document, f: ItemId| d.story(d.item(f).unwrap().text_frame().unwrap().story).unwrap().direction; + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), r(0.0), lid, "نص", ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), r(60.0), lid, "", ParaFormat::default()).unwrap(); + d.story_mut(sa).unwrap().direction = RightToLeft; + d.thread(a, b).unwrap(); + assert_eq!((direction(&d, a), direction(&d, b)), (RightToLeft, RightToLeft)); + // Threading the right-to-left story's second frame after a left-to-right frame: the frame + // left before it stays right to left. + let (h, _) = d.add_text_frame(SpreadRef::Doc(0), r(120.0), lid, "abc", ParaFormat::default()).unwrap(); + d.thread(h, b).unwrap(); + assert_eq!((direction(&d, a), direction(&d, b), direction(&d, h)), (RightToLeft, LeftToRight, LeftToRight)); + // Breaking a right-to-left thread leaves the frames after the break right to left. + let (c, _) = d.add_text_frame(SpreadRef::Doc(0), r(180.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(a, c).unwrap(); + d.unthread_after(a).unwrap(); + assert_ne!(d.item(c).unwrap().text_frame().unwrap().story, sa); + assert_eq!((direction(&d, a), direction(&d, c)), (RightToLeft, RightToLeft)); + d.check().unwrap(); +} + #[test] fn removing_last_frame_deletes_story() { let mut d = doc(); diff --git a/crates/engine/src/cmd/anchored.rs b/crates/engine/src/cmd/anchored.rs index 8e74baf..8c85c70 100644 --- a/crates/engine/src/cmd/anchored.rs +++ b/crates/engine/src/cmd/anchored.rs @@ -207,7 +207,7 @@ fn release(s: &mut Session, p: &Value) -> Result { let (spread, xf) = match placed { Some((fid, origin)) => { let loc = d0.find(fid).ok_or_else(|| bad("anchored.release", "frame not found"))?; - let fxf = d0.parent_xf(&loc) * d0.item(fid).map_or(Affine::IDENTITY, |i| i.text_xf()); + let fxf = d0.parent_xf(&loc) * d0.item(fid).map_or(Affine::IDENTITY, |i| d0.text_xf(i)); (loc.spread, fxf * Affine::translate(origin.to_vec2())) } None => (SpreadRef::Doc(0), Affine::IDENTITY), diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 60d5d10..40802d5 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -20,7 +20,7 @@ pub fn specs() -> Vec { "Create Frame", [], None, - "{spread?, rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, text?: string, caret?: bool}", + "{spread?, rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, text?: string, caret?: bool, vertical?: bool (text: a new vertical story)}", has_doc, frame_create ), @@ -479,7 +479,7 @@ pub fn specs() -> Vec { "Text Frame Options…", ["Object"], Some("Cmd+B"), - "{columns?, gutter?, inset?: number|[t,l,b,r], verticalJustification?: top|center|bottom|justify, firstBaseline?, autoSize?, ignoreWrap?, balanceColumns?, vertical?: bool (Vertical Type), ids?}", + "{columns?, gutter?, inset?: number|[t,l,b,r], verticalJustification?: top|center|bottom|justify, firstBaseline?, autoSize?, ignoreWrap?, balanceColumns?, vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", has_selection, text_frame_options ), @@ -815,10 +815,8 @@ fn frame_create(s: &mut Session, p: &Value) -> Result { if let Some(it) = d.item_mut(id) { it.path = path; it.shape = sh; - if vertical && let Some(tf) = it.text_frame_mut() { - tf.options.vertical = true; - } } + super::text::set_story_direction(d, &[sid], vertical); *sel = if caret { Selection::text(TextSel { story: sid, anchor: text.len(), focus: text.len(), frame: Some(id), cell: None }) } else { @@ -1452,9 +1450,6 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("ignoreWrap").and_then(Value::as_bool) { o.ignore_wrap = v; } - if let Some(v) = p.get("vertical").and_then(Value::as_bool) { - o.vertical = v; - } if let Some(v) = p.get("balanceColumns").and_then(Value::as_bool) { o.balance_columns = v; } @@ -1462,6 +1457,11 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { o.column_rule = v; } } + // Story direction belongs to the story: every frame of the thread turns. + if let Some(v) = p.get("vertical").and_then(Value::as_bool) { + let stories: Vec = ids.iter().filter_map(|id| d.item(*id).and_then(Item::text_frame).map(|t| t.story)).collect(); + super::text::set_story_direction(d, &stories, v); + } Ok(json!({"changed": ids.len()})) }) } diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index 700bfa5..b9d4790 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -273,9 +273,10 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result { } let (sr, _) = spread_xf(d, *id).ok_or(designcraft_doc::DocError::NoItem(*id))?; let at = d.spread(sr).and_then(|sp| sp.items.iter().position(|i| i.id == *id)); + let text_xf = d.text_xf(&it); let make = |d: &mut Document, ((fill, ft, stroke, stt, sw), bp): ((String, f32, String, f32, f64), BezPath)| { let mut o = Item::new(ItemId(d.alloc()), it.layer, Shape::Path, PathData::from_bezpath(&bp)); - o.xf = it.text_xf(); + o.xf = text_xf; o.fill = Fill { swatch: fill, tint: ft, ..Fill::none() }; o.stroke = if stroke == designcraft_color::swatch::NONE { Stroke::none() diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 574b3bf..c2f925c 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1150,7 +1150,7 @@ fn cell_at(s: &Session, frame: designcraft_doc::ItemId, pt: designcraft_geom::Po let loc = st.doc.find(frame)?; let it = st.doc.item_at(&loc)?; let sid = it.text_frame()?.story; - let inner = (st.doc.parent_xf(&loc) * it.text_xf()).inverse() * pt; + let inner = (st.doc.parent_xf(&loc) * st.doc.text_xf(it)).inverse() * pt; let cs = s.cache.get(&st.doc, sid, None); let fi = cs.frames.iter().position(|f| f.frame == frame)?; let (table, row, col, _) = designcraft_compose::hit_cell(&cs, fi, inner)?; @@ -1396,7 +1396,7 @@ mod sort_tests { let cells = &cs.frames[0].tables[0].cells; let centre = |r: usize| { let c = cells.iter().find(|c| c.row == r && c.col == 0).unwrap().rect.center(); - d.item(fid).unwrap().text_xf() * c + d.text_xf(d.item(fid).unwrap()) * c }; let (from, to) = (centre(0), centre(2)); s.execute("table.select", &json!({"story": sid.0, "table": tid, "rows": [0, 0], "what": "row"})).unwrap(); diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 6a68435..2885704 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -111,7 +111,7 @@ pub fn specs() -> Vec { }) } ), - cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, overset", has_doc, |s, p| { + 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 cs = s.cache.get(&st.doc, sid, None); @@ -206,6 +206,15 @@ pub fn specs() -> Vec { format_chars(s, &json!({"kenten": on})) } ), + 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.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!( @@ -257,7 +266,7 @@ fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Op 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) * it.text_xf(); + 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)?; @@ -596,6 +605,36 @@ fn word_bounds(s: &str, i: usize) -> (usize, usize) { (a, b) } +/// Type ▸ Story Direction. +fn story_direction(s: &mut Session, p: &Value) -> Result { + 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 = 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 += 1; + } + } +} + fn move_caret(s: &mut Session, p: &Value) -> Result { let dir = str_param(p, "dir").unwrap_or("right").to_string(); let extend = bool_or(p, "extend", false); @@ -1396,3 +1435,61 @@ mod korean_breaks_tests { 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 { + 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]); + } +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 5b4896c..6b57e2a 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -9,6 +9,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +use std::collections::HashSet; use std::io::{Cursor, Read, Write}; use std::sync::Arc; @@ -95,7 +96,9 @@ fn load_zip(bytes: &[u8]) -> Result { return Err(FormatError::TooNew(v as u32)); } let json = read(&mut z, "document.json").ok_or_else(|| FormatError::NotOurs("missing document.json".into()))?; - let mut doc: Document = serde_json::from_slice(&json).map_err(|e| FormatError::NotOurs(e.to_string()))?; + let mut v: Value = serde_json::from_slice(&json).map_err(|e| FormatError::NotOurs(e.to_string()))?; + upgrade(&mut v); + let mut doc: Document = serde_json::from_value(v).map_err(|e| FormatError::NotOurs(e.to_string()))?; let names: Vec = z.file_names().map(str::to_string).collect(); for name in names { let Some(stem) = name.strip_prefix("assets/") else { continue }; @@ -109,7 +112,8 @@ fn load_zip(bytes: &[u8]) -> Result { } fn load_legacy_json(bytes: &[u8]) -> Result { - let v: Value = serde_json::from_slice(bytes).map_err(|e| FormatError::NotOurs(e.to_string()))?; + let mut v: Value = serde_json::from_slice(bytes).map_err(|e| FormatError::NotOurs(e.to_string()))?; + upgrade(&mut v); let mut doc: Document = serde_json::from_value(v.clone()).map_err(|e| FormatError::NotOurs(e.to_string()))?; if let Some(data) = v.get("assetData").and_then(Value::as_object) { for (k, s) in data { @@ -124,6 +128,54 @@ fn load_legacy_json(bytes: &[u8]) -> Result { Ok(doc) } +/// Bring older documents' JSON up to the current model. Vertical Type was a text frame option +/// (`content.options.vertical`); it is the story's direction, which a story takes from its first +/// frame. +fn upgrade(doc: &mut Value) { + let mut vertical = HashSet::new(); + vertical_frames(doc, &mut vertical, 0); + if vertical.is_empty() { + return; + } + let Some(stories) = doc.get_mut("stories").and_then(Value::as_object_mut) else { return }; + for st in stories.values_mut() { + let first = st.get("frames").and_then(|f| f.get(0)).and_then(Value::as_u64); + if let (Some(f), Some(st)) = (first, st.as_object_mut()) + && vertical.contains(&f) + && !st.contains_key("vertical") + { + st.insert("vertical".into(), Value::Bool(true)); + } + } +} + +/// Ids of the text frames in `v` (any depth: groups, anchored objects) whose options say vertical. +fn vertical_frames(v: &Value, out: &mut HashSet, depth: usize) { + // serde_json already refuses JSON nested deeper than 128 levels. + if depth > 256 { + return; + } + match v { + Value::Object(m) => { + if let (Some(id), Some(c)) = (m.get("id").and_then(Value::as_u64), m.get("content")) + && c.get("type").and_then(Value::as_str) == Some("text") + && c.get("options").and_then(|o| o.get("vertical")).and_then(Value::as_bool) == Some(true) + { + out.insert(id); + } + for c in m.values() { + vertical_frames(c, out, depth + 1); + } + } + Value::Array(a) => { + for c in a { + vertical_frames(c, out, depth + 1); + } + } + _ => {} + } +} + fn base64_decode(s: &str) -> Vec { let mut out = Vec::with_capacity(s.len() * 3 / 4); let (mut buf, mut bits) = (0u32, 0); @@ -179,6 +231,48 @@ mod tests { assert_eq!(back.stories, d.stories); } + /// The object of the item `id` in a document's JSON. + fn item_json(v: &mut Value, id: u64) -> Option<&mut Value> { + if v.get("id").and_then(Value::as_u64) == Some(id) && v.get("content").is_some() { + return Some(v); + } + match v { + Value::Object(m) => m.values_mut().find_map(|c| item_json(c, id)), + Value::Array(a) => a.iter_mut().find_map(|c| item_json(c, id)), + _ => None, + } + } + + #[test] + fn frame_level_vertical_text_opens_as_a_vertical_story() { + // Documents that set Vertical on the frame: the story takes its first frame's direction. + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (a, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 200.0, 400.0), lid, "縦書き", ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(300.0, 72.0, 428.0, 400.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(a, b).unwrap(); + let (c, other) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(450.0, 72.0, 550.0, 400.0), lid, "横", ParaFormat::default()).unwrap(); + let mut v = serde_json::to_value(&d).unwrap(); + item_json(&mut v, a.0).unwrap()["content"]["options"]["vertical"] = json!(true); + item_json(&mut v, c.0).unwrap()["content"]["options"]["vertical"] = json!(false); + let json = serde_json::to_vec(&v).unwrap(); + let mut zipped = Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut zipped); + z.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(&json).unwrap(); + z.finish().unwrap(); + } + for bytes in [json.clone(), zipped.into_inner()] { + let back = load(&bytes).unwrap(); + assert!(back.stories[&sid].vertical); + assert!(!back.stories[&other].vertical); + // Saved again, the direction is the story's. + let again = load(&save(&back).unwrap()).unwrap(); + assert!(again.stories[&sid].vertical && !again.stories[&other].vertical); + } + } + #[test] fn rejects_garbage_and_newer_versions() { assert!(load(b"nope").is_err()); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index aaffe3b..5192aa3 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1837,14 +1837,13 @@ impl<'a> Ex<'a> { // ---------- stories ---------- fn story_el(&mut self, s: &Story) -> El { - let vertical = s.frames.iter().any(|f| self.d.item(*f).and_then(|i| i.text_frame()).is_some_and(|t| t.options.vertical)); let mut el = El::new("Story").attr("Self", uid(s.id.0)).attr("AppliedTOCStyle", "n").attr("TrackChanges", "false").attr("StoryTitle", "$ID/"); el.push( El::new("StoryPreference") .attr("OpticalMarginAlignment", "false") .attr("OpticalMarginSize", "12") .attr("FrameType", "TextFrameType") - .attr("StoryOrientation", if vertical { "Vertical" } else { "Horizontal" }) + .attr("StoryOrientation", if s.vertical { "Vertical" } else { "Horizontal" }) .attr( "StoryDirection", if s.direction == designcraft_doc::TextDirection::RightToLeft { "RightToLeftDirection" } else { "LeftToRightDirection" }, diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index c3ad423..493d6a0 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -115,8 +115,6 @@ struct Importer<'r> { layer_ids: HashMap, stories: BTreeMap, story_ids: HashMap, - /// Stories with a vertical StoryOrientation (their frames set text vertically). - vertical_stories: std::collections::HashSet, parents: Vec, parent_ids: HashMap, spreads: Vec, @@ -199,7 +197,6 @@ impl<'r> Importer<'r> { layers: Vec::new(), layer_ids: HashMap::new(), stories: BTreeMap::new(), - vertical_stories: Default::default(), story_ids: HashMap::new(), parents: Vec::new(), parent_ids: HashMap::new(), @@ -385,9 +382,6 @@ impl<'r> Importer<'r> { self.story_ids.insert(s.to_string(), id); } let story = self.story(id, e); - if e.find("StoryPreference").and_then(|p| p.get("StoryOrientation")) == Some("Vertical") { - self.vertical_stories.insert(id); - } self.stories.insert(id, story); } // Topic cross-references (See / See also) become markers at the start of the first story @@ -1423,6 +1417,7 @@ impl<'r> Importer<'r> { } else { designcraft_doc::TextDirection::LeftToRight }, + vertical: e.find("StoryPreference").and_then(|p| p.get("StoryOrientation")) == Some("Vertical"), rev: 0, tables, notes, @@ -2045,7 +2040,6 @@ impl<'r> Importer<'r> { } }; let mut options = e.find("TextFramePreference").map(text_frame_options).unwrap_or_default(); - options.vertical = self.vertical_stories.contains(&story); if let Some(g) = e.find("BaselineFrameGridOption") && g.get("UseCustomBaselineFrameGrid") == Some("true") { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 9211268..b0eb583 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -554,11 +554,19 @@ fn round_trips_frames_with_pasted_in_items() { fn vertical_story_orientation_round_trips() { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); - let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 200.0, 400.0), lid, "縦書き", ParaFormat::default()).unwrap(); - d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical = true; + let (a, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 200.0, 400.0), lid, "縦書き", ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(300.0, 72.0, 428.0, 400.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(a, b).unwrap(); + d.add_text_frame(SpreadRef::Doc(0), Rect::new(450.0, 72.0, 550.0, 400.0), lid, "横", ParaFormat::default()).unwrap(); + d.story_mut(sid).unwrap().vertical = true; + // Orientation and column direction are separate settings of the story. + d.story_mut(sid).unwrap().direction = designcraft_doc::TextDirection::RightToLeft; let back = import_idml(&export_idml(&d)).unwrap(); - let frame = back.spreads[0].items.iter().find_map(|i| i.text_frame()).unwrap(); - assert!(frame.options.vertical); + use designcraft_doc::TextDirection::{LeftToRight, RightToLeft}; + let directions: Vec<_> = back.stories.values().map(|s| (s.frames.len(), s.vertical, s.direction)).collect(); + assert!(directions.contains(&(2, true, RightToLeft)) && directions.contains(&(1, false, LeftToRight)), "{directions:?}"); + let frames: Vec = back.spreads[0].items.iter().map(|i| back.frame_vertical(i)).collect(); + assert_eq!(frames, [true, true, false]); } #[test] diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index a4e60c3..db2fe6f 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -895,7 +895,7 @@ impl Exporter<'_> { } } else if let Some(ft) = cs.frame(it.id) { let text = doc.story(tfr.story).map(|st| st.text.as_str()).unwrap_or(""); - let local = it.text_local(); + let local = doc.text_local(it); if local != Affine::IDENTITY { s.push_transform(&tf(local)); } diff --git a/crates/pdf/src/links.rs b/crates/pdf/src/links.rs index 393084c..ce81287 100644 --- a/crates/pdf/src/links.rs +++ b/crates/pdf/src/links.rs @@ -32,7 +32,7 @@ fn source_rects(doc: &Document, cache: &designcraft_compose::Cache, src: &Hyperl if loc.spread != SpreadRef::Doc(si) { continue; } - let m: Affine = doc.parent_xf(&loc) * it.text_xf(); + let m: Affine = doc.parent_xf(&loc) * doc.text_xf(it); for l in &ft.lines { if l.range.end <= *start || l.range.start >= *end { continue; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e86478c..6695d2c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -579,7 +579,7 @@ impl Renderer { if let Some(ft) = cs.frame(it.id) { match &tf.options.path { Some(pt) => self.draw_path_text(ctx, f, &cs, ft, xf, bp, pt), - None => self.draw_text(ctx, f, &cs, ft, xf * it.text_local()), + None => self.draw_text(ctx, f, &cs, ft, xf * f.doc.text_local(it)), } } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e1562c6..6753764 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -939,7 +939,7 @@ fn draw_text_selection( if let Some(sel) = cells.filter(|c| c.story == ts.story) { for ft in &cs.frames { let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue }; - let m = a * it.text_xf(); + let m = a * doc.text_xf(it); for t in ft.tables.iter().filter(|t| t.table == sel.table) { for c in t.cells.iter().filter(|c| sel.range.contains(c.row, c.col)) { let q = [ @@ -962,7 +962,7 @@ fn draw_text_selection( for (fi, ft) in cs.frames.iter().enumerate() { let Some((a, _)) = item_canvas_xf(doc, layout, ft.frame) else { continue }; let Some(it) = doc.item(ft.frame) else { continue }; - let m = a * it.text_xf(); + let m = a * doc.text_xf(it); // Show the frame edge while typing. for poly in path_screen(it, xf, a) { painter.add(egui::Shape::line(poly, Stroke::new(1.0, layer_color(doc, it).gamma_multiply(0.6)))); @@ -1022,7 +1022,7 @@ fn draw_text_selection( && let Some(ft) = cs.frames.get(fi) && let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) { - let m = a * it.text_xf(); + let m = a * doc.text_xf(it); let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0; if blink { let p0 = xf.to_screen(m * Point::new(x, bl - asc)); @@ -1053,7 +1053,7 @@ fn draw_cell_selection( let Some((fi, origin, text)) = found else { return }; let Some(ft) = cs.frames.get(fi) else { return }; let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { return }; - let m = a * it.text_xf() * designcraft_geom::Affine::translate(origin.to_vec2()); + let m = a * doc.text_xf(it) * designcraft_geom::Affine::translate(origin.to_vec2()); let range = ts.range(); if !range.is_empty() && let Some(cft) = text.frames.first() @@ -1081,7 +1081,7 @@ fn draw_cell_selection( None => compose::cell_caret(cs, cell.table, cell.row, cell.col, ts.focus), } { - let m = a * it.text_xf(); + let m = a * doc.text_xf(it); let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0; if blink { painter.line_segment( @@ -1485,7 +1485,7 @@ fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc let cs = app.session.cache.get(doc, story.id, None); for ft in &cs.frames { let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue }; - let m = a * it.text_xf(); + let m = a * doc.text_xf(it); let col = layer_color(doc, it); for l in &ft.lines { let size = (l.ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32; @@ -1553,7 +1553,7 @@ fn draw_dynamic_spelling(app: &DesignApp, ctx: &egui::Context, painter: &egui::P let red = Stroke::new(1.0, Color32::from_rgb(230, 30, 30)); for ft in &cs.frames { let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue }; - let m = a * it.text_xf(); + let m = a * doc.text_xf(it); for l in &ft.lines { for r in bad.iter().filter(|r| r.start < l.range.end && r.end > l.range.start) { let gs: Vec<_> = l.glyphs.iter().filter(|g| g.len > 0 && g.byte >= r.start && g.byte < r.end).collect(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c7b9f19..8f5401d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -407,8 +407,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:endnote.insert", "cmd:math.insert", ">Story Direction", - "cmd:object.textFrameOptions|Horizontal|{\"vertical\": false}", - "cmd:object.textFrameOptions|Vertical|{\"vertical\": true}", + "cmd:type.storyDirection|Horizontal|{\"vertical\": false}", + "cmd:type.storyDirection|Vertical|{\"vertical\": true}", "<", "cmd:type.tateChuYoko", "ui:app.rubyDialog", @@ -1681,6 +1681,15 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { "window.toolsDoubleColumn" => app.ui.tools_double_column, "view.togglePreview" => app.ui.screen_mode == crate::ScreenMode::Preview, "window.brightness" => params.get("brightness").and_then(Value::as_str) == Some(app.ui.brightness.id()), + "type.storyDirection" => { + let st = app.session.active()?; + let sid = st + .selection + .text + .map(|t| t.story) + .or_else(|| st.selection.items.iter().find_map(|i| st.doc.item(*i)?.text_frame().map(|t| t.story)))?; + st.doc.story(sid)?.vertical == params.get("vertical").and_then(Value::as_bool)? + } _ => return None, }) } @@ -2131,6 +2140,15 @@ mod tests { } } + #[test] + fn story_direction_is_checked() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("frame.create", &json!({"rect": [72, 72, 200, 400], "content": "text", "text": "縦", "vertical": true})).unwrap(); + assert_eq!(checked(&app, "type.storyDirection", &json!({"vertical": true})), Some(true)); + assert_eq!(checked(&app, "type.storyDirection", &json!({"vertical": false})), Some(false)); + } + #[test] fn proof_setup_and_colors() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/cjk.md b/docs/cjk.md index 6955571..fb7108b 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 frames, upright CJK, rotated Latin | done (per frame; 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 | | 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 | @@ -105,8 +105,7 @@ working (tsume removes the side bearings before mojikumi runs). shaper reports vertical advances; rotated (Latin) runs stay horizontal and turn as a whole. `vrt2` only for runs that are rotated, never with an extra turn. - Glyph placement in vertical lines uses the vertical origin, not a guessed centre. -- Story direction per story (IDML `StoryOrientation`) rather than per frame; frames of one story - agree. +- Story direction per story (IDML `StoryOrientation`); frames of one story agree. - Arrow keys and carets follow the vertical line direction. ### Annotations and inline structures