mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
T: tate-chu-yoko in vertical frames
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
a57a909db1
commit
fe40d4b80b
11 files changed
+140
-9
No files matched your search
+1
-1
@@ -105,7 +105,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
||||
- Colour management exposed: Edit ▸ Color Settings (`color.settings`: RGB/CMYK working spaces, intent, black-point compensation; `color.loadProfile` for ICC files; `color.convert` through the working spaces, with gamut checks) and View ▸ Proof Setup / Proof Colors (press, sRGB, legacy Mac RGB, colour-blindness simulations, simulate paper) on the canvas.
|
||||
- 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.
|
||||
- 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). Punctuation and brackets take their vertical forms (OpenType `vert`/`vrt2`) in vertical frames. Tate-chu-yoko and vertical metrics (`vmtx`) aren't there yet.
|
||||
- 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). Punctuation and brackets take their vertical forms (OpenType `vert`/`vrt2`) in vertical frames. 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. Vertical metrics (`vmtx`) and warichu 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).
|
||||
|
||||
@@ -101,6 +101,21 @@ pub struct PlacedGlyph {
|
||||
pub visible: bool,
|
||||
/// Stays upright in vertical frames (CJK ideographs, kana, hangul, full-width forms).
|
||||
pub upright: bool,
|
||||
/// Tate-chu-yoko placement (see [`shape::Glyph::tcy`]).
|
||||
pub tcy: Option<[f64; 3]>,
|
||||
}
|
||||
|
||||
impl PlacedGlyph {
|
||||
/// In a vertical frame, the turn that sets this glyph upright (about its em box centre, or
|
||||
/// across the line in a tate-chu-yoko group), applied after drawing it at `x` on `baseline`.
|
||||
pub fn vertical_xf(&self, baseline: f64) -> Option<designcraft_geom::Affine> {
|
||||
let turn = -std::f64::consts::FRAC_PI_2;
|
||||
if let Some([along, across, em]) = self.tcy {
|
||||
let c = Point::new(self.x + along, baseline + self.y - em * 0.38);
|
||||
return Some(designcraft_geom::Affine::rotate_about(turn, c) * designcraft_geom::Affine::translate((c.x + across - self.x, 0.0)));
|
||||
}
|
||||
self.upright.then(|| designcraft_geom::Affine::rotate_about(turn, Point::new(self.x + self.adv / 2.0, baseline + self.y - self.adv * 0.38)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Characters set upright (unrotated) in vertical text.
|
||||
@@ -1629,6 +1644,7 @@ fn tab_leader(tab: &Glyph, leader: &str, x: f64, w: f64, origin: f64, out: &mut
|
||||
len: 0,
|
||||
visible: true,
|
||||
upright: false,
|
||||
tcy: None,
|
||||
});
|
||||
at += adv;
|
||||
}
|
||||
@@ -1730,6 +1746,7 @@ fn place(g: &Glyph, x: f64) -> PlacedGlyph {
|
||||
len: g.len,
|
||||
visible,
|
||||
upright: upright_in_vertical(g.ch),
|
||||
tcy: g.tcy,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ pub struct Glyph {
|
||||
pub style: u32,
|
||||
/// Unbreakable (No Break).
|
||||
pub no_break: bool,
|
||||
/// Tate-chu-yoko in a vertical frame: [centre of the group's em along the line from `x`,
|
||||
/// this glyph's left edge across the line from that centre, the em] (see [`collapse_tcy`]).
|
||||
pub tcy: Option<[f64; 3]>,
|
||||
/// Width of a word space (U+0020) in this glyph's font, size and horizontal scale: the unit of
|
||||
/// word and letter spacing in justification.
|
||||
pub space: f64,
|
||||
@@ -255,9 +258,40 @@ pub(crate) fn shape_para(
|
||||
shape_run(db, &story.text, k..b, &props, auto_leading, style, sub, &mut glyphs);
|
||||
}
|
||||
}
|
||||
collapse_tcy(&mut glyphs, sub.vertical);
|
||||
ShapedPara { glyphs, range }
|
||||
}
|
||||
|
||||
/// Tate-chu-yoko: in vertical text each run of marked glyphs takes one em along the line, its
|
||||
/// glyphs set side by side across it; elsewhere the mark is dropped.
|
||||
pub(crate) fn collapse_tcy(glyphs: &mut [Glyph], vertical: bool) {
|
||||
let mut i = 0;
|
||||
while i < glyphs.len() {
|
||||
if glyphs[i].tcy.is_none() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let j = i + glyphs[i..].iter().position(|g| g.tcy.is_none()).unwrap_or(glyphs.len() - i);
|
||||
if !vertical {
|
||||
glyphs[i..j].iter_mut().for_each(|g| g.tcy = None);
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
let run = &mut glyphs[i..j];
|
||||
let width: f64 = run.iter().map(|g| g.adv).sum();
|
||||
let em = run.iter().map(|g| g.size).fold(0.0, f64::max);
|
||||
let step = em / run.len() as f64;
|
||||
let mut across = -width / 2.0;
|
||||
for (k, g) in run.iter_mut().enumerate() {
|
||||
g.tcy = Some([em / 2.0 - k as f64 * step, across + g.dx, em]);
|
||||
across += g.adv;
|
||||
g.adv = step;
|
||||
g.dx = 0.0;
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
|
||||
fn features_for(p: &CharProps) -> Vec<Feature> {
|
||||
let mut v = Vec::new();
|
||||
if !matches!(p.kerning, Kerning::Metrics | Kerning::Optical) {
|
||||
@@ -534,6 +568,7 @@ fn control_glyph(face: &Arc<FontFace>, p: &CharProps, auto_leading: TypeEnv, sty
|
||||
size,
|
||||
style,
|
||||
no_break: p.no_break,
|
||||
tcy: p.tate_chu_yoko.then_some([0.0; 3]),
|
||||
space: face.advance(face.glyph_for(' ')) * k * p.h_scale,
|
||||
}
|
||||
}
|
||||
@@ -603,6 +638,7 @@ fn shape_segment(
|
||||
size,
|
||||
style,
|
||||
no_break: p.no_break,
|
||||
tcy: p.tate_chu_yoko.then_some([0.0; 3]),
|
||||
space,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -841,6 +841,34 @@ fn bidi_matches_the_reference_order() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 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;
|
||||
}
|
||||
let gs = glyphs(&d);
|
||||
let digits: Vec<_> = gs.iter().filter(|g| g.tcy.is_some()).collect();
|
||||
assert_eq!(digits.len(), 2);
|
||||
let em = digits[0].tcy.unwrap()[2];
|
||||
// The pair takes one em along the line, so the next ideograph follows an em after it.
|
||||
assert!((digits.iter().map(|g| g.adv).sum::<f64>() - em).abs() < 1e-6);
|
||||
let nen = gs.iter().find(|g| g.byte == at + 2).unwrap();
|
||||
assert!((nen.x - (digits[0].x + em)).abs() < 1e-6, "{} {}", nen.x, digits[0].x);
|
||||
// Both turn about the same centre and sit side by side across it.
|
||||
let [a0, x0, _] = digits[0].tcy.unwrap();
|
||||
let [a1, x1, _] = digits[1].tcy.unwrap();
|
||||
assert!(((digits[0].x + a0) - (digits[1].x + a1)).abs() < 1e-6);
|
||||
assert!(x0 < 0.0 && x1 > x0 && x1 < em, "{x0} {x1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_frames_compose_in_the_turned_box() {
|
||||
let mut d = Document::new(&designcraft_doc::build::NewDocument::default());
|
||||
|
||||
@@ -478,6 +478,8 @@ attr_set! {
|
||||
strikethrough_tint: f32 = 1.0,
|
||||
ligatures: bool = true,
|
||||
no_break: bool = false,
|
||||
/// Tate-chu-yoko: in vertical text, the run is set horizontally within one em of the line.
|
||||
tate_chu_yoko: bool = false,
|
||||
language: String = "English: USA".into(),
|
||||
/// Additional OpenType features, e.g. `["onum", "ss01"]`.
|
||||
otf_features: Vec<String> = Vec::new(),
|
||||
|
||||
@@ -138,6 +138,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s,
|
||||
&json!({"capitalization": "allCaps"})
|
||||
)),
|
||||
cmd!(
|
||||
"type.tateChuYoko",
|
||||
"Tate-Chu-Yoko",
|
||||
[],
|
||||
None,
|
||||
"{on?: bool} — set the selected text horizontally within one em of a vertical line (toggles by default)",
|
||||
has_text_or_frames,
|
||||
|s, p| {
|
||||
let on = match p.get("on").and_then(Value::as_bool) {
|
||||
Some(v) => v,
|
||||
None => !format_targets(s).first().is_some_and(|t| {
|
||||
s.doc().ok().and_then(|d| d.doc.text_story(t.story, t.cell)).is_some_and(|st| {
|
||||
st.char_format_at(if t.range.is_empty() { t.range.start } else { t.range.start + 1 }).over.tate_chu_yoko == Some(true)
|
||||
})
|
||||
}),
|
||||
};
|
||||
format_chars(s, &json!({"tate_chu_yoko": on}))
|
||||
}
|
||||
),
|
||||
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!(
|
||||
@@ -1163,3 +1182,27 @@ mod language_tests {
|
||||
assert!(st.as_array().unwrap().is_empty(), "{st}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tcy_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn tate_chu_yoko_toggles_on_the_selection() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 400], "content": "text", "text": "令和12年", "vertical": true})).unwrap();
|
||||
let sid = r["story"].as_u64().unwrap();
|
||||
let at = "令和".len();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": at, "focus": at + 2})).unwrap();
|
||||
let get = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(at + 1).over.tate_chu_yoko;
|
||||
s.execute("type.tateChuYoko", &json!({})).unwrap();
|
||||
assert_eq!(get(&s), Some(true));
|
||||
s.execute("type.tateChuYoko", &json!({})).unwrap();
|
||||
assert_eq!(get(&s), Some(false));
|
||||
s.execute("type.tateChuYoko", &json!({"on": true})).unwrap();
|
||||
assert_eq!(get(&s), Some(true));
|
||||
}
|
||||
}
|
||||
@@ -1037,6 +1037,9 @@ impl<'a> Ex<'a> {
|
||||
if let Some(v) = a.no_break {
|
||||
el.set("NoBreak", bool_s(v));
|
||||
}
|
||||
if let Some(v) = a.tate_chu_yoko {
|
||||
el.set("Tatechuyoko", bool_s(v));
|
||||
}
|
||||
if let Some(list) = &a.conditions {
|
||||
el.set("AppliedConditions", list.iter().map(|c| format!("Condition/{}", escape_id(c))).collect::<Vec<_>>().join(" "));
|
||||
}
|
||||
|
||||
@@ -1069,6 +1069,7 @@ impl<'r> Importer<'r> {
|
||||
a.otf_features = Some(otf_list);
|
||||
}
|
||||
a.no_break = e.boolean("NoBreak");
|
||||
a.tate_chu_yoko = e.boolean("Tatechuyoko");
|
||||
if let Some(v) = e.get("AppliedConditions").filter(|v| !v.trim().is_empty()) {
|
||||
let names: Vec<String> = v.split_whitespace().filter_map(|r| self.condition_names.get(r).cloned()).collect();
|
||||
if !names.is_empty() {
|
||||
|
||||
@@ -116,16 +116,17 @@ impl Exporter<'_> {
|
||||
continue;
|
||||
}
|
||||
// Vertical type: an upright glyph turns back a quarter about its em box centre.
|
||||
if ft.vertical && g.upright {
|
||||
let c = designcraft_geom::Point::new(g.x + g.adv / 2.0, l.baseline + g.y - g.adv * 0.38);
|
||||
s.push_transform(&crate::export::tf(Affine::rotate_about(-std::f64::consts::FRAC_PI_2, c)));
|
||||
if ft.vertical
|
||||
&& let Some(turn) = g.vertical_xf(l.baseline)
|
||||
{
|
||||
s.push_transform(&crate::export::tf(turn));
|
||||
self.tagged_run(s, cs, &gs[i..i + 1], l.baseline, story);
|
||||
s.pop();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut j = i + 1;
|
||||
while j < gs.len() && same_run(g, &gs[j]) && !(ft.vertical && gs[j].upright) {
|
||||
while j < gs.len() && same_run(g, &gs[j]) && !(ft.vertical && (gs[j].upright || gs[j].tcy.is_some())) {
|
||||
j += 1;
|
||||
}
|
||||
self.tagged_run(s, cs, &gs[i..j], l.baseline, story);
|
||||
|
||||
@@ -132,10 +132,9 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line
|
||||
}
|
||||
let skew = if style.skew != 0.0 { Affine::new([1.0, 0.0, -style.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
let mut a = Affine::translate((g.x, l.baseline + g.y)) * skew * Affine::scale_non_uniform(g.sx, g.sy);
|
||||
if vertical && g.upright {
|
||||
// Upright in vertical text: turned back a quarter about the em box centre.
|
||||
let c = kurbo::Point::new(g.x + g.adv / 2.0, l.baseline + g.y - g.adv * 0.38);
|
||||
a = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, c) * a;
|
||||
if vertical && let Some(turn) = g.vertical_xf(l.baseline) {
|
||||
// Upright in vertical text.
|
||||
a = turn * a;
|
||||
}
|
||||
let bp = match runs.iter_mut().find(|r| r.0 == g.style) {
|
||||
Some(r) => &mut r.1,
|
||||
|
||||
@@ -393,6 +393,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
||||
"cmd:object.textFrameOptions|Horizontal|{\"vertical\": false}",
|
||||
"cmd:object.textFrameOptions|Vertical|{\"vertical\": true}",
|
||||
"<",
|
||||
"cmd:type.tateChuYoko",
|
||||
">Track Changes",
|
||||
"cmd:changes.track",
|
||||
"cmd:changes.acceptAll",
|
||||
|
||||
Reference in new issue
Block a user