From 6df33fa3446da9cb7e9817a03c9dc99135823fe4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 7 Oct 2026 09:09:35 -0400 Subject: [PATCH] CJK 1.7: story direction belongs to the story Vertical is a story property: every frame of a thread is vertical or horizontal together, a frame threaded onto a story takes its direction, and frames split off by threading or breaking a thread keep theirs. Type > Story Direction is type.storyDirection {vertical} (checked in the menu) for the stories of the text selection or selected frames; object.textFrameOptions {vertical} and frame.create {vertical} set the frames' stories. Documents that stored Vertical on the frame open with each story taking its first frame's direction. IDML StoryOrientation is read into and written from the story, beside its column direction (StoryDirection), which stays a separate setting. --- ROADMAP.md | 2 +- crates/compose/src/lib.rs | 13 ++-- crates/compose/src/notes.rs | 1 + crates/compose/src/table.rs | 1 + crates/compose/src/tests.rs | 16 +++-- crates/doc/src/edit.rs | 29 ++++++++- crates/doc/src/item.rs | 21 ------- crates/doc/src/story.rs | 5 ++ crates/doc/src/tests.rs | 52 +++++++++++++++ crates/engine/src/cmd/anchored.rs | 2 +- crates/engine/src/cmd/object.rs | 16 ++--- crates/engine/src/cmd/paths.rs | 3 +- crates/engine/src/cmd/table.rs | 4 +- crates/engine/src/cmd/text.rs | 101 +++++++++++++++++++++++++++++- crates/format/src/lib.rs | 98 ++++++++++++++++++++++++++++- crates/idml/src/export.rs | 3 +- crates/idml/src/import.rs | 8 +-- crates/idml/src/tests.rs | 16 +++-- crates/pdf/src/export.rs | 2 +- crates/pdf/src/links.rs | 2 +- crates/render/src/lib.rs | 2 +- crates/ui-egui/src/canvas.rs | 14 ++--- crates/ui-egui/src/menus.rs | 22 ++++++- docs/cjk.md | 5 +- 24 files changed, 356 insertions(+), 82 deletions(-) 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