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.
This commit is contained in:
aiongg 2026-10-07 09:09:35 -04:00
parent 1574dcdd45
commit 6df33fa344
24 changed files with 356 additions and 82 deletions

View File

@ -111,7 +111,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Keyboard: Tab / Shift+Tab hide and show all panels / all but Tools (`window.hidePanels`, `window.hidePanelsExceptTools`); Cmd+F6 / Cmd+Shift+F6 cycle documents; panel fields are reached with Tab and Escape returns to the layout.
- Smart guides and snapping (View ▸ Snap to Guides / Snap to Document Grid / Smart Guides, `view.snapPreferences` and Preferences ▸ Guides & Pasteboard): moving, drawing, resizing, rotating and pen/anchor drags snap in order to the document and baseline grids, ruler guides, margins and columns, other objects' edges and centres (parent-page items too, by visible stroked bounds), equal spacing, matching dimensions and matching rotation, with guides, gap marks and measurements drawn on the canvas. Shift and Cmd constrain the gesture.
- View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread.
- Vertical type (Vertical Type Tool, Type ▸ Story Direction, `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).

View File

@ -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<Exclusion>,
pub page_name: Option<String>,
/// Absolute document page the frame is on (None on parent pages).
@ -398,7 +401,8 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
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<FrameSpec> {
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() {

View File

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

View File

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

View File

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

View File

@ -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<ItemId> {
@ -348,6 +370,7 @@ impl Document {
"direction": st.direction,
"paragraphs": st.paras.len(),
"frames": st.frames.iter().map(|f| f.0).collect::<Vec<_>>(),
"vertical": st.vertical,
"text": st.text,
}))
}

View File

@ -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<PathType>,
/// 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]);

View File

@ -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(),

View File

@ -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();

View File

@ -207,7 +207,7 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
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),

View File

@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"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<Value> {
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<Value> {
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<Value> {
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<StoryId> = 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()}))
})
}

View File

@ -273,9 +273,10 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result<Value> {
}
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()

View File

@ -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();

View File

@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
})
}
),
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<CommandSpec> {
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<Value> {
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<StoryId> = 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<Value> {
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<bool> {
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]);
}
}

View File

@ -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<Document, FormatError> {
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<String> = 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<Document, FormatError> {
}
fn load_legacy_json(bytes: &[u8]) -> Result<Document, FormatError> {
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<Document, FormatError> {
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<u64>, 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<u8> {
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());

View File

@ -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" },

View File

@ -115,8 +115,6 @@ struct Importer<'r> {
layer_ids: HashMap<String, LayerId>,
stories: BTreeMap<StoryId, Story>,
story_ids: HashMap<String, StoryId>,
/// Stories with a vertical StoryOrientation (their frames set text vertically).
vertical_stories: std::collections::HashSet<StoryId>,
parents: Vec<Spread>,
parent_ids: HashMap<String, SpreadId>,
spreads: Vec<Spread>,
@ -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")
{

View File

@ -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<bool> = back.spreads[0].items.iter().map(|i| back.frame_vertical(i)).collect();
assert_eq!(frames, [true, true, false]);
}
#[test]

View File

@ -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));
}

View File

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

View File

@ -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)),
}
}
}

View File

@ -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();

View File

@ -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<bool> {
"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());

View File

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