Vertical type (tategaki): lay out, render, edit and round-trip (#199) (#246) (#246)

This commit is contained in:
Brandon Thomas 2026-10-05 11:48:14 -04:00 committed by GitHub
parent 5a5faccd4a
commit ae80df2eb4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1042 additions and 62 deletions

View File

@ -217,7 +217,7 @@ pub enum TextShape {
pub enum Orientation {
#[default]
Horizontal,
/// Vertical type: modelled for round trip, rendered horizontally for now.
/// Vertical type (tategaki): columns top to bottom, advancing right to left.
Vertical,
}

View File

@ -128,8 +128,7 @@ fn set_orientation(s: &mut Session, p: &Value, o: Orientation) -> Result<Value>
t.orientation = o;
Ok(())
})?;
// Vertical layout is stored and saved (PSD `Ornt`), but rendered horizontally for now.
Ok(json!({"orientation": if o == Orientation::Vertical { "vertical" } else { "horizontal" }, "rendered": "horizontal"}))
Ok(json!({"orientation": if o == Orientation::Vertical { "vertical" } else { "horizontal" }}))
}
// ---------- point ⇄ paragraph ----------
@ -137,10 +136,26 @@ fn set_orientation(s: &mut Session, p: &Value, o: Orientation) -> Result<Value>
fn to_paragraph(s: &mut Session, p: &Value) -> Result<Value> {
let r = with_text(s, p, "Convert to Paragraph Text", |t, doc| {
let l = layout(doc, t);
let [x0, y0, x1, y1] = l.bounds().unwrap_or([0.0, -t.size_pt, t.size_pt, 0.0]);
let size_px = t.char_runs().iter().map(|r| r.style.size_pt).fold(t.size_pt, f32::max) * doc.resolution_dpi / 72.0;
let ps = t.paragraph_runs().first().map(|p| p.style.clone()).unwrap_or_default();
let indents = (ps.start_indent_pt + ps.end_indent_pt).max(0.0) * doc.resolution_dpi / 72.0;
if l.vertical {
// Line space: u along the columns, v across them (first column centred on v = 0).
let [u0, v0, u1, v1] = l.line_bounds().unwrap_or([0.0, -size_px / 2.0, size_px, size_px / 2.0]);
let height = (u1 - u0).max(1.0) + indents + (size_px * 0.5).max(4.0);
let width = (v1 - v0).max(1.0) + size_px * 0.5;
let y = match ps.align {
TextAlign::Center | TextAlign::JustifyCenter => -height / 2.0,
TextAlign::Right | TextAlign::JustifyRight => -height,
_ => 0.0,
};
// The box's right edge is the first column's right edge, so the columns stay put.
let x = -v0 - width;
t.shape = TextShape::Box { x: 0.0, y: 0.0, width, height };
t.transform = then_translate(&t.transform, f64::from(x), f64::from(y));
return Ok(json!([x, y, width, height]));
}
let [x0, y0, x1, y1] = l.bounds().unwrap_or([0.0, -t.size_pt, t.size_pt, 0.0]);
// A little slack so no line re-wraps; the box keeps the text where it was.
let width = (x1 - x0).max(1.0) + indents + (size_px * 0.5).max(4.0);
let height = (y1 - y0).max(1.0) + size_px * 0.5;
@ -164,8 +179,10 @@ fn to_paragraph(s: &mut Session, p: &Value) -> Result<Value> {
fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
let r = with_text(s, p, "Convert to Point Text", |t, doc| {
let TextShape::Box { x, width, .. } = t.shape else { return Err(EngineError::Other("already point text".into())) };
let TextShape::Box { x, y, width, height } = t.shape else { return Err(EngineError::Other("already point text".into())) };
let l = layout(doc, t);
// Vertical type: columns run along the box height.
let (x, width) = if l.vertical { (y, height) } else { (x, width) };
let baseline = l.lines.first().map_or(0.0, |ln| ln.baseline);
// Photoshop inserts a hard return at every soft line break.
let mut breaks: Vec<usize> = l.lines.windows(2).filter(|w| w[0].paragraph == w[1].paragraph).map(|w| w[1].range.start).collect();
@ -181,8 +198,9 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
_ => x,
};
t.shape = TextShape::Point;
// `baseline` is in text space (it already includes the box top).
t.transform = then_translate(&t.transform, f64::from(ax), f64::from(baseline));
// `baseline` is in line space (it already includes the box edge).
let (tx, ty) = l.to_text(ax, baseline);
t.transform = then_translate(&t.transform, f64::from(tx), f64::from(ty));
Ok(breaks.len())
})?;
Ok(json!({"inserted": r}))

View File

@ -73,6 +73,73 @@ fn point_and_paragraph_conversion_keep_the_text_in_place() {
}
}
/// Issue #199: vertical type is laid out top to bottom, columns right to left, on the canvas.
#[test]
fn vertical_orientation_renders_columns_inside_the_canvas() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 1000, "height": 600, "background": "#fff8e7", "name": "flyer"})).unwrap();
// The issue's repro (its font may be missing here: fallback fonts then cover the text).
let r = s.execute("type.create", json!({"x": 900, "y": 30, "text": "縦書きテスト", "size": 40, "font": "Noto Sans CJK JP", "name": "vertical"})).unwrap();
let id = LayerId(r["layer"].as_u64().unwrap());
let flat = ink(&s, id);
assert!(flat.width() > flat.height(), "horizontal first: {flat:?}");
let v = s.execute("type.orientation.vertical", json!({})).unwrap();
assert_eq!(v, json!({"orientation": "vertical"}));
let b = ink(&s, id);
assert!(b.height() > b.width(), "{b:?}");
assert!(b.x0 >= 0 && b.x1 <= 1000 && b.y0 >= 0 && b.y1 <= 600, "inside the canvas: {b:?}");
// Centred on the anchor's x, running down from its y.
assert!(b.x0 < 900 && b.x1 > 900 && b.y0 >= 25, "{b:?}");
// Latin text: a second column of a two-line layer sits left of the first.
let r = s.execute("type.create", json!({"x": 500, "y": 30, "text": "ABC\nDEF", "size": 40})).unwrap();
let two = LayerId(r["layer"].as_u64().unwrap());
s.execute("type.orientation.vertical", json!({"layer": two.0})).unwrap();
let st = s.active().unwrap();
let l = layout(&st.doc, text(&s, two));
assert!(l.vertical && l.lines.len() == 2);
let [x0, y0, x1, y1] = l.bounds().unwrap();
assert!(x1 > 0.0 && x0 < -40.0 && y0 >= -1e-3 && y1 > 60.0, "{:?}", l.bounds());
// Bad layer ids fail cleanly.
assert!(s.execute("type.orientation.vertical", json!({"layer": 9999})).is_err());
}
#[test]
fn vertical_point_and_paragraph_conversion_keep_the_columns_in_place() {
for align in ["left", "center", "right"] {
let mut s = session(8);
s.execute("image.canvasSize", json!({"width": 300, "height": 400})).ok();
let r = s.execute("type.create", json!({"x": 150, "y": 100, "text": "Hello world", "size": 30, "align": align})).unwrap();
let id = LayerId(r["layer"].as_u64().unwrap());
s.execute("type.orientation.vertical", json!({})).unwrap();
let st = s.active().unwrap();
let before = layout(&st.doc, text(&s, id)).bounds().unwrap();
let before_tf = text(&s, id).transform;
let map = |t: &photocraft_geom::Affine, b: [f32; 4]| {
let p = t.apply(photocraft_geom::Point::new(f64::from(b[0]), f64::from(b[1])));
(p.x, p.y)
};
let doc_before = map(&before_tf, before);
s.execute("type.convertToParagraphText", json!({})).unwrap();
let TextShape::Box { height, .. } = text(&s, id).shape else { panic!("box expected") };
let st = s.active().unwrap();
let l = layout(&st.doc, text(&s, id));
assert_eq!(l.lines.len(), 1, "{align}: no new wraps");
assert!(height > 0.0);
// The column's first glyph stays where it was (glyph positions in document space).
let g_after = l.glyphs[0];
let tf = text(&s, id).transform;
let p_after = tf.apply(photocraft_geom::Point::new(f64::from(g_after.x), f64::from(g_after.y)));
s.execute("type.convertToPointText", json!({})).unwrap();
let st = s.active().unwrap();
let back = layout(&st.doc, text(&s, id));
let g_back = back.glyphs[0];
let p_back = text(&s, id).transform.apply(photocraft_geom::Point::new(f64::from(g_back.x), f64::from(g_back.y)));
assert!((p_after.x - p_back.x).abs() < 1.5 && (p_after.y - p_back.y).abs() < 1.5, "{align}: {p_after:?} → {p_back:?}");
let back_doc = map(&text(&s, id).transform, back.bounds().unwrap());
assert!((back_doc.0 - doc_before.0).abs() < 1.5 && (back_doc.1 - doc_before.1).abs() < 1.5, "{align}: {doc_before:?} → {back_doc:?}");
}
}
#[test]
fn paragraph_to_point_hardens_soft_wraps() {
let mut s = session(8);

View File

@ -0,0 +1,65 @@
//! Vertical type (#199): orientation survives a PSD round trip (descriptor `Ornt` and the
//! engine data's writing direction), point and paragraph text, and the re-rendered pixels after
//! import are the vertical layout's (taller than wide).
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::text::{CharStyle, Orientation, TextRun, TextShape};
use photocraft_doc::{Document, Layer, LayerContent, Size, TextLayer};
use photocraft_geom::Affine;
fn doc_with(shape: TextShape) -> Document {
let mut doc = Document::new("v", Size::new(300, 400), ColorMode::Rgb, SampleType::U8);
let text = "Vertical type\nColumns";
let mut t = TextLayer {
text: text.into(),
runs: vec![TextRun { len: text.len(), style: CharStyle { font_family: "Inter".into(), size_pt: 24.0, ..Default::default() } }],
orientation: Orientation::Vertical,
shape,
transform: Affine::translate(250.0, 20.0),
..Default::default()
};
t.sync_summary();
photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format());
doc.layers.push(Layer::new("v", LayerContent::Text(t)));
doc
}
fn text(doc: &Document) -> &TextLayer {
match &doc.layers[0].content {
LayerContent::Text(t) => t,
_ => panic!("not text"),
}
}
#[test]
fn vertical_orientation_round_trips_through_psd() {
for shape in [TextShape::Point, TextShape::Box { x: -200.0, y: 0.0, width: 220.0, height: 300.0 }] {
let doc = doc_with(shape);
let ink = text(&doc).cache.as_ref().unwrap().content_bounds();
assert!(ink.height() > ink.width(), "{shape:?}: {ink:?}");
let out = photocraft_io::export(&doc, "v.psd", &Default::default()).unwrap();
let back = photocraft_io::import("v.psd", &out.bytes).unwrap().document;
let t = text(&back);
assert_eq!(t.orientation, Orientation::Vertical, "{shape:?}");
assert_eq!(t.shape, shape);
assert_eq!(t.text, text(&doc).text);
// Re-rendering the imported model gives the same vertical layout.
let mut again = t.clone();
photocraft_text::TextEngine::new().render_layer(&mut again, back.resolution_dpi, back.pixel_format());
let r = again.cache.as_ref().unwrap().content_bounds();
assert!(
(r.x0 - ink.x0).abs() <= 1 && (r.y0 - ink.y0).abs() <= 1 && (r.x1 - ink.x1).abs() <= 1 && (r.y1 - ink.y1).abs() <= 1,
"{shape:?}: {ink:?} → {r:?}"
);
// A second trip keeps it, and horizontal stays horizontal.
let out2 = photocraft_io::export(&back, "v.psd", &Default::default()).unwrap();
assert_eq!(text(&photocraft_io::import("v.psd", &out2.bytes).unwrap().document).orientation, Orientation::Vertical);
}
let mut doc = doc_with(TextShape::Point);
if let LayerContent::Text(t) = &mut doc.layers[0].content {
t.orientation = Orientation::Horizontal;
t.psd_raw = None;
}
let out = photocraft_io::export(&doc, "h.psd", &Default::default()).unwrap();
assert_eq!(text(&photocraft_io::import("h.psd", &out.bytes).unwrap().document).orientation, Orientation::Horizontal);
}

View File

@ -5,6 +5,14 @@
//! breaking); paragraphs are then stacked with Photoshop's rules: baseline-to-baseline distance =
//! the largest leading on the line (auto leading = paragraph factor × size), space before/after,
//! indents, and point-text alignment around the anchor.
//!
//! **Vertical type** (tategaki) is laid out in the same *line space* and then turned 90° clockwise
//! into text space: a line becomes a column running top to bottom, and successive columns advance
//! right to left. Within a column, CJK characters stay upright (centred on the column, placed with
//! the font's vertical metrics: `VORG`, `vhea`/`vmtx`, else the ideographic em box) and use the
//! font's vertical alternates (`vert`) for punctuation, brackets and the long-vowel mark; other
//! text (Latin, digits) runs rotated 90° clockwise, Photoshop's default. Lines, clusters, carets
//! and hit tests stay in line space; [`TextLayout::to_text`] / [`TextLayout::to_line`] convert.
use std::borrow::Cow;
use std::ops::Range;
@ -14,13 +22,79 @@ use parley::{
PositionedLayoutItem, StyleProperty,
};
use photocraft_doc::TextLayer;
use photocraft_doc::text::{Caps, CharStyle, Kerning, TextAlign, TextDirection, TextShape};
use photocraft_doc::text::{Caps, CharStyle, Kerning, Orientation, TextAlign, TextDirection, TextShape};
use crate::fonts::FontDb;
/// Index of the character run whose style a glyph uses.
/// Index of the character run whose style a glyph uses, plus the vertical-type class of its
/// characters ([`VClass`] as `u8`; always 0 in horizontal type).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct RunBrush(pub u32);
pub struct RunBrush(pub u32, pub u8);
/// How a character is set in vertical type (a simplification of Unicode's
/// `Vertical_Orientation`, UAX #50).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum VClass {
/// Rotated 90° clockwise (Latin, digits, most symbols).
Rotate = 0,
/// Upright (ideographs, kana, Hangul, full-width forms, CJK punctuation such as 、。),
/// using a vertical alternate when the font has one.
Upright = 1,
/// Upright through its vertical alternate (brackets, the long-vowel mark, dashes); rotated
/// when the font has none.
AlternateOrRotate = 2,
}
/// The vertical-type class of a character.
pub fn vertical_class(c: char) -> VClass {
let u = c as u32;
let alt = matches!(
u,
0x2014..=0x2016
| 0x2025
| 0x2026
| 0x2329
| 0x232A
| 0x3008..=0x3011
| 0x3014..=0x301F
| 0x3030
| 0x30A0
| 0x30FC
| 0xFE59..=0xFE5E
| 0xFF08
| 0xFF09
| 0xFF0D
| 0xFF1A..=0xFF1E
| 0xFF3B
| 0xFF3D
| 0xFF3F
| 0xFF5B..=0xFF60
| 0xFFE3
);
if alt {
return VClass::AlternateOrRotate;
}
// Half-width katakana and Hangul rotate like other half-width text.
if (0xFF61..=0xFFDC).contains(&u) {
return VClass::Rotate;
}
let upright = crate::cjk::classify(c).is_some()
|| matches!(u, 0xA7 | 0xA9 | 0xAE | 0xB1 | 0xBC..=0xBE | 0xD7 | 0xF7 | 0x2E80..=0x2EFF | 0xFE10..=0xFE1F | 0xFE30..=0xFE4F | 0x1F000..=0x1FAFF);
if upright { VClass::Upright } else { VClass::Rotate }
}
/// How a placed glyph's outline is oriented in text space.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum GlyphOrient {
/// Horizontal type: origin on the baseline, outline upright.
#[default]
Horizontal,
/// Vertical type, upright: the origin is the glyph's horizontal origin (left end of its
/// advance, on its baseline); baseline shift moves it right.
Upright,
/// Vertical type, rotated 90° clockwise about its origin (the baseline runs downwards).
Rotated,
}
/// A font instance used by some glyphs.
#[derive(Clone, Debug)]
@ -44,6 +118,8 @@ pub struct PlacedGlyph {
pub y: f32,
/// Character run index (into [`TextLayout::styles`]).
pub style: u32,
/// Orientation of the outline (vertical type).
pub orient: GlyphOrient,
}
/// Underline/strikethrough rectangle (text space px).
@ -56,6 +132,9 @@ pub struct DecorationRect {
pub style: u32,
}
/// A line (a column in vertical type) in line space: `x` runs along the line and `baseline` is the
/// cross-line position. In vertical type `baseline` is the column's centre line and
/// `ascent`/`descent` are the column's half widths on either side.
#[derive(Clone, Debug, PartialEq)]
pub struct LineInfo {
/// Byte range in the layer text (without the paragraph break).
@ -69,7 +148,7 @@ pub struct LineInfo {
pub paragraph: usize,
}
/// A grapheme cluster (caret stops, hit testing, selection).
/// A grapheme cluster (caret stops, hit testing, selection), in line space.
#[derive(Clone, Debug, PartialEq)]
pub struct ClusterInfo {
pub range: Range<usize>,
@ -91,11 +170,30 @@ pub struct TextLayout {
pub styles: Vec<CharStyle>,
/// Pixels per point used (dpi / 72).
pub px_per_pt: f32,
/// Vertical type: line space is turned 90° clockwise into text space.
pub vertical: bool,
}
impl TextLayout {
/// Line space → text space: the identity for horizontal type; for vertical type the line
/// direction points down and the cross-line direction (towards the next line) points left.
pub fn to_text(&self, x: f32, y: f32) -> (f32, f32) {
if self.vertical { (-y, x) } else { (x, y) }
}
/// Text space → line space (inverse of [`Self::to_text`]).
pub fn to_line(&self, x: f32, y: f32) -> (f32, f32) {
if self.vertical { (y, -x) } else { (x, y) }
}
/// Logical bounds (x0, y0, x1, y1) of all lines, text space.
pub fn bounds(&self) -> Option<[f32; 4]> {
let b = self.line_bounds()?;
Some(if self.vertical { [-b[3], b[0], -b[1], b[2]] } else { b })
}
/// Logical bounds of all lines in line space (equal to [`Self::bounds`] for horizontal type).
pub fn line_bounds(&self) -> Option<[f32; 4]> {
self.lines.iter().fold(None, |acc, l| {
let r = [l.x0, l.baseline - l.ascent, l.x1, l.baseline + l.descent];
Some(match acc {
@ -107,6 +205,12 @@ impl TextLayout {
/// Byte offset of the caret nearest to a text-space point.
pub fn hit_test(&self, x: f32, y: f32) -> usize {
let (x, y) = self.to_line(x, y);
self.hit_test_line(x, y)
}
/// Byte offset of the caret nearest to a line-space point.
pub fn hit_test_line(&self, x: f32, y: f32) -> usize {
let Some((li, line)) = self.lines.iter().enumerate().min_by(|a, b| {
let d = |l: &LineInfo| {
if y < l.baseline - l.ascent {
@ -133,11 +237,19 @@ impl TextLayout {
best.1
}
/// Caret geometry for a byte offset: (x, top, bottom) in text space.
/// The caret for a byte offset as a text-space segment (its two end points).
pub fn caret_segment(&self, offset: usize) -> [(f32, f32); 2] {
let (x, top, bottom) = self.caret(offset);
[self.to_text(x, top), self.to_text(x, bottom)]
}
/// Caret geometry for a byte offset: (x, top, bottom) in line space (the same as text space
/// for horizontal type; see [`Self::caret_segment`]).
pub fn caret(&self, offset: usize) -> (f32, f32, f32) {
for c in &self.clusters {
if c.range.start == offset {
let l = &self.lines[c.line];
if c.range.start == offset
&& let Some(l) = self.lines.get(c.line)
{
let x = if c.rtl { c.x + c.advance } else { c.x };
return (x, l.baseline - l.ascent, l.baseline + l.descent);
}
@ -176,7 +288,8 @@ impl Layouter {
let k = if dpi > 0.0 { dpi / 72.0 } else { 1.0 };
let runs = t.char_runs();
let paras = t.paragraph_runs();
let mut out = TextLayout { px_per_pt: k, ..Default::default() };
let vertical = t.orientation == Orientation::Vertical;
let mut out = TextLayout { px_per_pt: k, vertical, ..Default::default() };
// Resolve families (PostScript names from PSDs, unknown families).
for r in &runs {
let mut s = r.style.clone();
@ -264,6 +377,35 @@ impl Layouter {
for p in style_props(st, k, &fallback, ri as u32) {
b.push(p, range.clone());
}
if vertical {
// Upright pieces get the vertical alternates and their class in the brush.
let piece = ptext.get(range.clone()).unwrap_or("");
let mut seg: Option<(usize, VClass)> = None;
let flush = |b: &mut parley::RangedBuilder<'_, RunBrush>, from: usize, to: usize, cls: VClass| {
if cls == VClass::Rotate || from >= to {
return;
}
let mut feats = feature_list(st);
feats.push("\"vert\" 1".into());
let r = (range.start + from)..(range.start + to);
b.push(StyleProperty::FontFeatures(FontFeatures::Source(Cow::Owned(feats.join(", ")))), r.clone());
b.push(StyleProperty::Brush(RunBrush(ri as u32, cls as u8)), r);
};
for (i, ch) in piece.char_indices() {
let cls = vertical_class(ch);
match seg {
Some((_, c)) if c == cls => {}
Some((from, c)) => {
flush(&mut b, from, i, c);
seg = Some((i, cls));
}
None => seg = Some((i, cls)),
}
}
if let Some((from, c)) = seg {
flush(&mut b, from, piece.len(), c);
}
}
}
b.build(&ptext)
};
@ -272,7 +414,9 @@ impl Layouter {
if ps.first_line_indent_pt != 0.0 {
layout.set_text_indent(ps.first_line_indent_pt * k, IndentOptions::default());
}
let avail = if is_box { Some((box_rect.2 - indent_start - indent_end).max(1.0)) } else { None };
// Box extent along the lines: width, or height for vertical type (columns).
let (line_origin, line_len) = if vertical { (box_rect.1, box_rect.3) } else { (box_rect.0, box_rect.2) };
let avail = if is_box { Some((line_len - indent_start - indent_end).max(1.0)) } else { None };
layout.break_all_lines(avail);
let alignment = if is_box {
match ps.align {
@ -306,8 +450,25 @@ impl Layouter {
let st = &out.styles[style_at(prange.start)];
leading = st.leading_pt.map_or(ps.auto_leading * st.size_pt * k, |l| l * k);
}
let (ascent, descent) = if m.ascent > 0.0 || m.descent > 0.0 { (m.ascent, m.descent) } else { (first_px * 0.8, first_px * 0.2) };
// Vertical type: half the column width (the largest em on the column).
let col_half = line
.items()
.filter_map(|it| match it {
PositionedLayoutItem::GlyphRun(gr) => Some(gr.run().font_size() * 0.5),
_ => None,
})
.fold(0.0f32, f32::max);
let col_half = if col_half > 0.0 { col_half } else { first_px * 0.5 };
let (ascent, descent) = if vertical {
(col_half, col_half)
} else if m.ascent > 0.0 || m.descent > 0.0 {
(m.ascent, m.descent)
} else {
(first_px * 0.8, first_px * 0.2)
};
let baseline = match prev_baseline {
// The first column's right edge touches the box's right edge.
None if is_box && vertical => -(box_rect.0 + box_rect.2) + col_half,
// Photoshop's "first baseline: ascent": the top of the tallest ascender
// (height of 'd') touches the box top, not the font's hhea ascent.
None if is_box => box_rect.1 + first_ascent(&line).unwrap_or(ascent),
@ -315,7 +476,8 @@ impl Layouter {
Some(b) => b + leading + pending_space,
};
pending_space = 0.0;
if is_box && baseline + descent > box_rect.1 + box_rect.3 + 0.5 {
let overflow = if vertical { baseline + descent > -box_rect.0 + 0.5 } else { baseline + descent > box_rect.1 + box_rect.3 + 0.5 };
if is_box && overflow {
stop = true;
break;
}
@ -323,7 +485,7 @@ impl Layouter {
let last_line = li + 1 == nlines;
let adv = m.advance - m.trailing_whitespace;
let dx = if is_box {
let base = box_rect.0 + indent_start;
let base = line_origin + indent_start;
let slack = avail.unwrap_or(0.0) - adv;
base + if last_line {
match ps.align {
@ -348,6 +510,7 @@ impl Layouter {
let lr = line.text_range();
let g0 = out.glyphs.len();
let c0 = out.clusters.len();
let mut vinfo: Vec<VGlyph> = Vec::new();
let mut extra = 0.0f32; // horizontal-scale growth along the line
let mut seen_runs: Vec<usize> = Vec::new();
for item in line.items() {
@ -364,7 +527,7 @@ impl Layouter {
font: run.font().clone(),
coords: run.normalized_coords().to_vec(),
size_px: run.font_size(),
embolden: synth.embolden(),
embolden: synthetic_bold(synth.embolden(), st.weight, run.font_attrs().weight.value()),
skew_deg: synth.skew().unwrap_or(0.0),
});
let run_x0 = dx + gr.offset() + extra;
@ -380,24 +543,65 @@ impl Layouter {
cx += a;
}
}
let rm = run.metrics();
let vm = if vertical { Some(VMetrics::new(run.font(), run.normalized_coords(), run.font_size())) } else { None };
let class = gr.style().brush.1;
// Glyphs of the run's own characters (not substituted by `vert`).
let plain: Vec<u32> = match (&vm, class) {
(Some(v), 2) => ptext.get(run.text_range()).unwrap_or("").chars().filter_map(|c| v.cmap(c)).collect(),
_ => Vec::new(),
};
let mut pen = gr.offset();
for g in gr.glyphs() {
out.glyphs.push(PlacedGlyph { face, id: g.id, x: dx + pen + g.x + extra, y: baseline + g.y, style: si });
out.glyphs.push(PlacedGlyph {
face,
id: g.id,
x: dx + pen + g.x + extra,
y: baseline + g.y,
style: si,
orient: GlyphOrient::Horizontal,
});
if let Some(v) = &vm {
let upright = class == 1 || (class == 2 && !plain.contains(&g.id));
vinfo.push(VGlyph {
upright,
advance: g.advance * hs,
origin: if upright { v.origin(g.id) } else { 0.0 },
// Rotated text is centred on the column: its baseline sits
// (ascent − descent) / 2 from the centre line.
centre: (rm.ascent - rm.descent) * 0.5,
});
}
extra += g.advance * (hs - 1.0);
pen += g.advance;
}
let run_x1 = dx + gr.offset() + gr.advance() + extra;
let rm = run.metrics();
let shift = st.baseline_shift_pt * k;
if gr.style().underline.is_some() {
if vertical {
// Underline to the right of the column, strikethrough through its centre
// (stored directly in text space).
let half = run.font_size() * 0.5;
let mut push = |v0: f32, v1: f32| out.decorations.push(DecorationRect { x0: -v1, y0: run_x0, x1: -v0, y1: run_x1, style: si });
if gr.style().underline.is_some() {
let v1 = baseline - half;
push(v1 - rm.underline_size.max(1.0), v1);
}
if gr.style().strikethrough.is_some() {
let sz = rm.strikethrough_size.max(1.0);
push(baseline - sz * 0.5, baseline + sz * 0.5);
}
} else if gr.style().underline.is_some() {
let y0 = baseline - rm.underline_offset - shift;
out.decorations.push(DecorationRect { x0: run_x0, y0, x1: run_x1, y1: y0 + rm.underline_size.max(1.0), style: si });
}
if gr.style().strikethrough.is_some() {
if !vertical && gr.style().strikethrough.is_some() {
let y0 = baseline - rm.strikethrough_offset - shift;
out.decorations.push(DecorationRect { x0: run_x0, y0, x1: run_x1, y1: y0 + rm.strikethrough_size.max(1.0), style: si });
}
}
if vertical {
extra -= squeeze_punctuation(text, &mut out.clusters[c0..], &mut out.glyphs[g0..]);
}
if justify_all {
let slack = avail.unwrap_or(0.0) - (adv + extra);
let n = out.clusters.len() - c0;
@ -414,6 +618,21 @@ impl Layouter {
extra += slack;
}
}
if vertical {
// Line space → text space (90° clockwise); upright glyphs centred on the column.
for (g, v) in out.glyphs[g0..].iter_mut().zip(&vinfo) {
let (u, cross) = (g.x, g.y);
if v.upright {
g.x = -baseline - v.advance * 0.5;
g.y = u + v.origin;
g.orient = GlyphOrient::Upright;
} else {
g.x = -(cross + v.centre);
g.y = u;
g.orient = GlyphOrient::Rotated;
}
}
}
let x0 = dx + m.offset;
out.lines.push(LineInfo {
range: map(lr.start)..map(lr.end).min(content_end),
@ -467,14 +686,104 @@ fn quote(s: &str) -> String {
format!("\"{}\"", s.replace(['\\', '"'], ""))
}
fn style_props(st: &CharStyle, k: f32, fallback: &[String], idx: u32) -> Vec<StyleProperty<'static, RunBrush>> {
let px = (st.size_pt * k).max(0.01);
let mut fam: Vec<String> = Vec::new();
if !st.font_family.is_empty() {
fam.push(quote(&st.font_family));
/// Placement data of one vertical-type glyph (before mapping to text space).
struct VGlyph {
upright: bool,
/// Advance along the column (px).
advance: f32,
/// Upright: distance from the top of the glyph's cell down to its baseline (px).
origin: f32,
/// Rotated: distance from the column centre line to the glyph's baseline (px).
centre: f32,
}
/// Vertical metrics of a font instance: `VORG`, `vhea`/`vmtx`, else the ideographic em box.
struct VMetrics<'a> {
font: Option<skrifa::FontRef<'a>>,
coords: Vec<skrifa::instance::NormalizedCoord>,
/// px per font unit.
scale: f32,
/// Em-box fallback origin (px below the cell top).
em_origin: f32,
}
impl<'a> VMetrics<'a> {
fn new(fd: &'a FontData, coords: &[i16], size: f32) -> Self {
use skrifa::MetadataProvider;
let font = skrifa::FontRef::from_index(fd.data.as_ref(), fd.index).ok();
let coords: Vec<skrifa::instance::NormalizedCoord> = coords.iter().map(|&c| skrifa::instance::NormalizedCoord::from_bits(c)).collect();
let (scale, em_origin) = match &font {
Some(f) => {
let m = f.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::new(&coords));
let upem = f32::from(m.units_per_em).max(1.0);
let (asc, desc) = (m.ascent, -m.descent);
// Ideographic em box: the em centred on the font's ascent/descent span.
let r = if asc + desc > 0.0 && asc.is_finite() && desc.is_finite() { (asc / (asc + desc)).clamp(0.0, 1.0) } else { 0.88 };
(size / upem, size * r)
}
None => (0.0, size * 0.88),
};
Self { font, coords, scale, em_origin }
}
fam.extend(fallback.iter().map(|f| quote(f)));
fam.push("sans-serif".into());
fn cmap(&self, c: char) -> Option<u32> {
use skrifa::MetadataProvider;
self.font.as_ref()?.charmap().map(c).map(|g| g.to_u32())
}
/// Distance from the top of an upright glyph's cell to its baseline (px).
fn origin(&self, gid: u32) -> f32 {
self.vertical_origin(gid).filter(|v| v.is_finite()).unwrap_or(self.em_origin)
}
fn vertical_origin(&self, gid: u32) -> Option<f32> {
use skrifa::MetadataProvider;
use skrifa::raw::TableProvider;
let f = self.font.as_ref()?;
let g = skrifa::GlyphId::new(gid);
if let Ok(vorg) = f.vorg() {
return Some(f32::from(vorg.vertical_origin_y(g)) * self.scale);
}
let tsb = f.vmtx().ok()?.side_bearing(g)?;
// Top of the outline (font units), from the outline itself (glyf and CFF alike).
let outline = f.outline_glyphs().get(g)?;
let mut pen = YMax(None);
let settings = skrifa::outline::DrawSettings::unhinted(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::new(&self.coords));
outline.draw(settings, &mut pen).ok()?;
Some((pen.0? + f32::from(tsb)) * self.scale)
}
}
/// Outline pen recording the largest y.
struct YMax(Option<f32>);
impl YMax {
fn add(&mut self, y: f32) {
self.0 = Some(self.0.map_or(y, |m| m.max(y)));
}
}
impl skrifa::outline::OutlinePen for YMax {
fn move_to(&mut self, _x: f32, y: f32) {
self.add(y);
}
fn line_to(&mut self, _x: f32, y: f32) {
self.add(y);
}
fn quad_to(&mut self, _cx0: f32, cy0: f32, _x: f32, y: f32) {
self.add(cy0);
self.add(y);
}
fn curve_to(&mut self, _cx0: f32, cy0: f32, _cx1: f32, cy1: f32, _x: f32, y: f32) {
self.add(cy0);
self.add(cy1);
self.add(y);
}
fn close(&mut self) {}
}
/// OpenType feature settings of a character style (CSS `font-feature-settings` items).
fn feature_list(st: &CharStyle) -> Vec<String> {
let mut feats: Vec<String> = Vec::new();
if st.kerning == Kerning::Off {
feats.push("\"kern\" 0".into());
@ -494,6 +803,18 @@ fn style_props(st: &CharStyle, k: f32, fallback: &[String], idx: u32) -> Vec<Sty
feats.push(format!("\"{}\" {}", f.tag, f.value));
}
}
feats
}
fn style_props(st: &CharStyle, k: f32, fallback: &[String], idx: u32) -> Vec<StyleProperty<'static, RunBrush>> {
let px = (st.size_pt * k).max(0.01);
let mut fam: Vec<String> = Vec::new();
if !st.font_family.is_empty() {
fam.push(quote(&st.font_family));
}
fam.extend(fallback.iter().map(|f| quote(f)));
fam.push("sans-serif".into());
let feats = feature_list(st);
let vars: Vec<String> = st.variations.iter().filter(|v| v.axis.len() == 4 && v.axis.is_ascii()).map(|v| format!("\"{}\" {}", v.axis, v.value)).collect();
vec![
StyleProperty::FontFamily(FontFamily::Source(Cow::Owned(fam.join(", ")))),
@ -505,11 +826,74 @@ fn style_props(st: &CharStyle, k: f32, fallback: &[String], idx: u32) -> Vec<Sty
StyleProperty::LetterSpacing(st.tracking / 1000.0 * px),
StyleProperty::Underline(st.underline),
StyleProperty::Strikethrough(st.strikethrough),
StyleProperty::Brush(RunBrush(idx)),
StyleProperty::Brush(RunBrush(idx, 0)),
StyleProperty::Locale(st.language.as_deref().and_then(|l| parley::fontique::Language::parse(l).ok())),
]
}
/// Closing CJK punctuation: its full-width cell has blank space after the mark.
fn is_closing(c: char) -> bool {
matches!(c, '、' | '。' | ',' | '.' | '」' | '』' | ')' | '〕' | '】' | '〉' | '》' | '〙' | '〗' | ']' | '}')
}
/// Opening CJK brackets: blank space before the mark.
fn is_opening(c: char) -> bool {
matches!(c, '「' | '『' | '(' | '〔' | '【' | '〈' | '《' | '〘' | '〖' | '[' | '{')
}
/// Basic Japanese punctuation squeeze (JIS X 4051 style) for one line in line space: between a
/// closing mark and a following opening or closing mark, the closing mark's trailing half-em
/// blank is removed; between two opening brackets, the second one's leading half-em. Clusters and
/// glyphs from the squeezed point on move back. Returns the total length removed.
pub(crate) fn squeeze_punctuation(text: &str, clusters: &mut [ClusterInfo], glyphs: &mut [PlacedGlyph]) -> f32 {
let first = |c: &ClusterInfo| text.get(c.range.clone()).and_then(|s| s.chars().next());
let last = |c: &ClusterInfo| text.get(c.range.clone()).and_then(|s| s.chars().next_back());
// Shift to apply from each cluster on.
let mut shifts = vec![0.0f32; clusters.len()];
let mut total = 0.0f32;
for i in 1..clusters.len() {
let (Some(prev), Some(cur)) = (clusters.get(i - 1), clusters.get(i)) else { continue };
if prev.rtl || cur.rtl {
continue;
}
let (Some(a), Some(b)) = (last(prev), first(cur)) else { continue };
let cut = if is_closing(a) && (is_opening(b) || is_closing(b)) {
prev.advance * 0.5
} else if is_opening(a) && is_opening(b) {
cur.advance * 0.5
} else {
0.0
};
if cut.is_finite() && cut > 0.0 {
total += cut;
}
if let Some(s) = shifts.get_mut(i) {
*s = total;
}
}
if total <= 0.0 {
return 0.0;
}
let starts: Vec<f32> = clusters.iter().map(|c| c.x).collect();
for (c, s) in clusters.iter_mut().zip(&shifts) {
c.x -= s;
}
for g in glyphs.iter_mut() {
let i = starts.iter().rposition(|&s| s <= g.x + 1e-3).unwrap_or(0);
g.x -= shifts.get(i).copied().unwrap_or(0.0);
}
total
}
/// Whether to embolden a face synthetically. Font matching (fontique) asks for it whenever the
/// requested weight is above the face's, so Regular (400) from a family with only W3 (300) and W6
/// (600), like Hiragino Mincho ProN, came out as a smeared bold W3. Like Photoshop and CSS, only
/// a bold request (≥ 600) on a face that isn't bold (≤ 500) is emboldened; anything else uses the
/// nearest face as it is (Faux Bold stays a separate, explicit style).
pub(crate) fn synthetic_bold(matcher_says: bool, requested: u16, face_weight: f32) -> bool {
matcher_says && requested >= 600 && face_weight <= 500.0
}
/// Height of the lowercase ascender ('d') of the tallest run on the line.
fn first_ascent(line: &parley::Line<'_, RunBrush>) -> Option<f32> {
use skrifa::MetadataProvider;

View File

@ -89,3 +89,5 @@ pub fn shared() -> &'static std::sync::Mutex<TextEngine> {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod vertical_tests;

View File

@ -136,6 +136,10 @@ pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option<TextLayer> {
}
let k = 72.0 / if dpi > 0.0 { dpi } else { 72.0 };
if let Some(e) = engine_data(&t.text) {
// Without an `Ornt` key, the engine data's writing direction says it (2 = vertical).
if enum_value(&t.text, "Ornt").is_none() && e.path(&["EngineDict", "Rendered", "Shapes", "WritingDirection"]).and_then(E::as_f64) == Some(2.0) {
layer.orientation = Orientation::Vertical;
}
apply_engine_data(&mut layer, &e, txt.as_deref(), k);
} else {
layer.text = txt.unwrap_or_default().replace('\r', "\n");
@ -544,17 +548,20 @@ pub fn build_engine_data(layer: &TextLayer, template: Option<E>, dpi: f32) -> E
("TransformPoint2".into(), E::Array(vec![real(0.0), real(0.0)])),
]),
);
// EngineData writing direction and procession, as Photoshop writes vertical type: 2 and 1
// (horizontal: 0 and 0). The descriptor's `Ornt` agrees.
let writing = if layer.orientation == Orientation::Vertical { 2 } else { 0 };
let child = E::Dict(vec![
("ShapeType".into(), E::Int(shape_type)),
("Procession".into(), E::Int(0)),
("Lines".into(), E::Dict(vec![("WritingDirection".into(), E::Int(0)), ("Children".into(), E::Array(vec![]))])),
("Procession".into(), E::Int(i64::from(writing == 2))),
("Lines".into(), E::Dict(vec![("WritingDirection".into(), E::Int(writing)), ("Children".into(), E::Array(vec![]))])),
("Cookie".into(), E::Dict(vec![("Photoshop".into(), photoshop)])),
]);
dict.set(
"Rendered",
E::Dict(vec![
("Version".into(), E::Int(1)),
("Shapes".into(), E::Dict(vec![("WritingDirection".into(), E::Int(0)), ("Children".into(), E::Array(vec![child]))])),
("Shapes".into(), E::Dict(vec![("WritingDirection".into(), E::Int(writing)), ("Children".into(), E::Array(vec![child]))])),
]),
);

View File

@ -8,7 +8,7 @@ use skrifa::instance::{LocationRef, NormalizedCoord, Size};
use skrifa::outline::DrawSettings;
use skrifa::{GlyphId, MetadataProvider};
use crate::layout::TextLayout;
use crate::layout::{GlyphOrient, PlacedGlyph, TextLayout};
use crate::raster::{Bounds, Coverage, LineSink, Pen, Xform, rect_to};
use crate::warp::Warp;
@ -66,12 +66,7 @@ fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_c
continue;
};
let coords: Vec<NormalizedCoord> = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect();
let hs = if st.horizontal_scale > 0.0 { st.horizontal_scale } else { 1.0 } as f64;
let vs = if st.vertical_scale > 0.0 { st.vertical_scale } else { 1.0 } as f64;
let skew_deg = if st.faux_italic { FAUX_ITALIC_DEG } else { 0.0 } + face.skew_deg;
let skew = (skew_deg as f64).to_radians().tan() * vs;
let shift = (st.baseline_shift_pt * layout.px_per_pt) as f64;
let glyph = Xform([hs, 0.0, skew, -vs, g.x as f64, g.y as f64 - shift]);
let glyph = glyph_xform(layout, g, face.skew_deg);
let bold = st.faux_bold || face.embolden;
let r = (face.size_px * FAUX_BOLD_RADIUS) as f64;
let offsets: &[(f64, f64)] =
@ -111,6 +106,25 @@ fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_c
}
}
/// Font units (scaled to px, y up) → text space for a placed glyph: scales, faux italic,
/// baseline shift, and the vertical-type orientation.
fn glyph_xform(layout: &TextLayout, g: &PlacedGlyph, face_skew_deg: f32) -> Xform {
let Some(st) = layout.styles.get(g.style as usize) else { return Xform([1.0, 0.0, 0.0, -1.0, g.x as f64, g.y as f64]) };
let hs = if st.horizontal_scale > 0.0 { st.horizontal_scale } else { 1.0 } as f64;
let vs = if st.vertical_scale > 0.0 { st.vertical_scale } else { 1.0 } as f64;
let skew_deg = if st.faux_italic { FAUX_ITALIC_DEG } else { 0.0 } + face_skew_deg;
let skew = (skew_deg as f64).to_radians().tan() * vs;
let shift = (st.baseline_shift_pt * layout.px_per_pt) as f64;
let (x, y) = (g.x as f64, g.y as f64);
match g.orient {
GlyphOrient::Horizontal => Xform([hs, 0.0, skew, -vs, x, y - shift]),
// Baseline shift moves upright glyphs in vertical type to the right.
GlyphOrient::Upright => Xform([hs, 0.0, skew, -vs, x + shift, y]),
// 90° clockwise: the glyph's up direction (and its baseline shift) points right.
GlyphOrient::Rotated => Xform([0.0, 1.0, -1.0, 0.0, x, y]).mul(&Xform([hs, 0.0, skew, -vs, 0.0, -shift])),
}
}
/// The warp to apply to `layout` (None when `warp` is absent, `warpNone` or flat).
pub fn layout_warp(layout: &TextLayout, warp: Option<&photocraft_doc::text::TextWarp>) -> Option<Warp> {
Warp::new(warp?, layout.bounds()?)
@ -264,7 +278,6 @@ impl skrifa::outline::OutlinePen for Recorder<'_> {
pub fn outlines(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) -> Vec<Vec<PathEl>> {
let mut glyphs = Vec::new();
for g in &layout.glyphs {
let st = &layout.styles[g.style as usize];
let face = &layout.faces[g.face as usize];
let Ok(font) = skrifa::FontRef::from_index(face.font.data.as_ref(), face.font.index) else {
continue;
@ -273,12 +286,7 @@ pub fn outlines(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) ->
continue;
};
let coords: Vec<NormalizedCoord> = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect();
let hs = if st.horizontal_scale > 0.0 { st.horizontal_scale } else { 1.0 } as f64;
let vs = if st.vertical_scale > 0.0 { st.vertical_scale } else { 1.0 } as f64;
let skew_deg = if st.faux_italic { FAUX_ITALIC_DEG } else { 0.0 } + face.skew_deg;
let skew = (skew_deg as f64).to_radians().tan() * vs;
let shift = (st.baseline_shift_pt * layout.px_per_pt) as f64;
let glyph = Xform([hs, 0.0, skew, -vs, g.x as f64, g.y as f64 - shift]);
let glyph = glyph_xform(layout, g, face.skew_deg);
let mut rec = Recorder { glyph, post: Xform(transform.m), warp, out: Vec::new() };
let settings = DrawSettings::unhinted(Size::new(face.size_px), LocationRef::new(&coords));
if outline.draw(settings, &mut rec).is_ok() && !rec.out.is_empty() {

View File

@ -0,0 +1,278 @@
//! Vertical type (tategaki): column flow, upright CJK, vertical alternates, rotated Latin,
//! box wrapping, carets and hit tests. Tests needing a CJK font use an installed system font
//! and skip (with a note) when none is found.
use photocraft_color::PixelFormat;
use photocraft_doc::TextLayer;
use photocraft_doc::text::{CharStyle, Orientation, TextRun, TextShape};
use photocraft_geom::Affine;
use crate::TextEngine;
use crate::layout::{GlyphOrient, VClass, vertical_class};
fn vertical(text: &str, family: &str, size_pt: f32) -> TextLayer {
TextLayer {
text: text.into(),
runs: vec![TextRun { len: text.len(), style: CharStyle { font_family: family.into(), size_pt, ..Default::default() } }],
orientation: Orientation::Vertical,
..Default::default()
}
}
/// An engine with an installed Japanese font registered, and that font's family.
fn cjk_engine() -> Option<(TextEngine, String)> {
#[cfg(not(target_arch = "wasm32"))]
for script in [crate::cjk::CjkScript::Japanese, crate::cjk::CjkScript::SimplifiedChinese] {
for f in crate::cjk::font_files(script) {
let Ok(bytes) = std::fs::read(&f.path) else { continue };
let mut e = TextEngine::new();
let fams = e.fonts.register_font_data(bytes);
let want = if f.family.is_empty() { None } else { fams.iter().find(|n| n.as_str() == f.family) };
if let Some(fam) = want.or(fams.first()).cloned() {
return Some((e, fam));
}
}
}
eprintln!("no CJK system font found: skipping");
None
}
#[test]
fn classes_follow_vertical_orientation() {
for c in ['縦', 'か', 'カ', '한', '、', '。', 'A', '1'] {
assert_eq!(vertical_class(c), VClass::Upright, "{c}");
}
for c in ['ー', '「', '」', '(', ')', '〜', '…', '—'] {
assert_eq!(vertical_class(c), VClass::AlternateOrRotate, "{c}");
}
for c in ['A', 'z', '1', ' ', '-', 'カ', 'é'] {
assert_eq!(vertical_class(c), VClass::Rotate, "{c}");
}
}
#[test]
fn latin_is_rotated_and_runs_down_columns_right_to_left() {
let mut e = TextEngine::new();
let l = e.layout(&vertical("abc\nde", "Inter", 20.0), 72.0);
assert!(l.vertical);
assert_eq!(l.lines.len(), 2);
assert!(l.glyphs.iter().all(|g| g.orient == GlyphOrient::Rotated));
let (a, b, c, d) = (l.glyphs[0], l.glyphs[1], l.glyphs[2], l.glyphs[3]);
// Down the first column, on one vertical baseline.
assert!(a.y < b.y && b.y < c.y, "{a:?} {b:?} {c:?}");
assert!((a.x - b.x).abs() < 1e-3 && (b.x - c.x).abs() < 1e-3);
// The second column is to the left (by the leading) and starts at the top again.
assert!((a.x - d.x - 24.0).abs() < 1e-3, "{a:?} {d:?}");
assert!((d.y - a.y).abs() < 1e-3);
// Rotated text is centred on the column through the anchor (x = 0): its baseline sits left.
assert!(a.x < 0.0 && a.x > -10.0, "{a:?}");
// Bounds: one column for "abc" is taller than wide.
let one = e.layout(&vertical("abcdef", "Inter", 20.0), 72.0);
let [x0, y0, x1, y1] = one.bounds().unwrap();
assert!((y1 - y0) > 2.0 * (x1 - x0), "{:?}", one.bounds());
assert!((x0 + 10.0).abs() < 1e-3 && (x1 - 10.0).abs() < 1e-3 && y0.abs() < 1e-3);
}
#[test]
fn rotated_render_ink_is_taller_than_wide() {
let mut e = TextEngine::new();
let horizontal = TextLayer { orientation: Orientation::Horizontal, ..vertical("Vertical", "Inter", 30.0) };
let (_, h) = e.render(&TextLayer { transform: Affine::translate(100.0, 50.0), ..horizontal.clone() }, 72.0, PixelFormat::RGBA8);
let (_, v) = e.render(&TextLayer { transform: Affine::translate(100.0, 50.0), orientation: Orientation::Vertical, ..horizontal }, 72.0, PixelFormat::RGBA8);
assert!(h.rect.width() > h.rect.height());
assert!(v.rect.height() > 2 * v.rect.width(), "{:?}", v.rect);
// The rotated word is the horizontal word turned: same ink length.
assert!(v.rect.height().abs_diff(h.rect.width()) <= 3, "{:?} {:?}", v.rect, h.rect);
// It runs down from the anchor, centred on x = 100.
assert!(v.rect.y0 >= 49 && v.rect.x0 < 100 && v.rect.x1 > 100, "{:?}", v.rect);
}
#[test]
fn caret_hit_test_and_arrow_geometry_follow_columns() {
let mut e = TextEngine::new();
let l = e.layout(&vertical("abc\ndef", "Inter", 20.0), 72.0);
// Above the first column's top → offset 0; below it → end of the first line.
assert_eq!(l.hit_test(0.0, -5.0), 0);
assert_eq!(l.hit_test(0.0, 1000.0), 3);
// Left of the anchor is the second column.
assert_eq!(l.hit_test(-24.0, -5.0), 4);
// The caret is a horizontal segment across the column, lower for later offsets.
let [(x0, y0), (x1, y1)] = l.caret_segment(1);
assert!((y0 - y1).abs() < 1e-4 && x0 > x1 && (x0 - x1 - 20.0).abs() < 1e-3, "{x0} {y0} {x1} {y1}");
let [(_, y2), _] = l.caret_segment(2);
assert!(y2 > y0);
// Round trip through line space.
let (u, v) = l.to_line(-7.0, 33.0);
assert_eq!(l.to_text(u, v), (-7.0, 33.0));
}
#[test]
fn box_text_wraps_columns_within_the_box_height() {
let mut e = TextEngine::new();
let t = TextLayer { shape: TextShape::Box { x: 10.0, y: 20.0, width: 200.0, height: 60.0 }, ..vertical("ab cd ef gh ij kl mn op", "Inter", 20.0) };
let l = e.layout(&t, 72.0);
assert!(l.lines.len() >= 3, "{}", l.lines.len());
let [x0, y0, x1, y1] = l.bounds().unwrap();
// Columns start at the box's right edge and wrap within its height.
assert!((x1 - 210.0).abs() < 1e-3, "{x1}");
assert!(x0 >= 10.0 - 1e-3 && y0 >= 20.0 - 1e-3 && y1 <= 80.0 + 1e-3, "{:?}", l.bounds());
for g in &l.glyphs {
assert!(g.y >= 20.0 - 1e-3 && g.y <= 80.0 + 1e-3, "{g:?}");
}
// A narrow box drops the columns that don't fit (like overflowing lines).
let narrow = TextLayer { shape: TextShape::Box { x: 0.0, y: 0.0, width: 30.0, height: 60.0 }, ..t };
assert_eq!(e.layout(&narrow, 72.0).lines.len(), 1);
}
#[test]
fn horizontal_layout_is_unchanged_by_the_vertical_path() {
let mut e = TextEngine::new();
let t = TextLayer { orientation: Orientation::Horizontal, ..vertical("abc", "Inter", 20.0) };
let l = e.layout(&t, 72.0);
assert!(!l.vertical && l.glyphs.iter().all(|g| g.orient == GlyphOrient::Horizontal));
assert_eq!(l.bounds(), l.line_bounds());
assert_eq!(l.to_text(3.0, 4.0), (3.0, 4.0));
}
#[test]
fn cjk_is_upright_centred_with_vertical_alternates() {
let Some((mut e, fam)) = cjk_engine() else { return };
let text = "縦書き、テスト。ーA";
let l = e.layout(&vertical(text, &fam, 40.0), 72.0);
let h = e.layout(&TextLayer { orientation: Orientation::Horizontal, ..vertical(text, &fam, 40.0) }, 72.0);
let g = &l.glyphs;
assert_eq!(g.len(), text.chars().count(), "{fam}");
assert!(g.iter().all(|g| g.id != 0), "{fam}: no .notdef");
// Ideographs and kana: upright, centred on the column (full-width advance ≈ 40 px).
for (i, gl) in g.iter().take(8).enumerate() {
assert_eq!(gl.orient, GlyphOrient::Upright, "{fam}: glyph {i}");
assert!((gl.x + 20.0).abs() < 4.0, "{fam}: glyph {i} at {gl:?}");
}
// Positions go down the column.
assert!(g.windows(2).all(|w| w[1].y > w[0].y), "{fam}: {g:?}");
// The first glyph's baseline is inside its em cell (vertical origin within the em).
assert!(g[0].y > 20.0 && g[0].y < 40.0, "{fam}: {:?}", g[0]);
// 、 and 。 use their vertical alternates (different glyphs than in horizontal type).
for i in [3usize, 7] {
assert_ne!(g[i].id, h.glyphs[i].id, "{fam}: char {i} should use its vertical alternate");
}
// ー: upright through its alternate when the font has one, else rotated.
let dash = g[8];
if dash.id != h.glyphs[8].id {
assert_eq!(dash.orient, GlyphOrient::Upright);
} else {
assert_eq!(dash.orient, GlyphOrient::Rotated);
}
// Latin inside vertical CJK runs rotated.
assert_eq!(g[9].orient, GlyphOrient::Rotated);
// Punctuation squeeze: 、「 takes 1.5 em down the column, ト、 keeps full cells.
let s = e.layout(&vertical("ト、「東", &fam, 40.0), 72.0);
let cx: Vec<f32> = s.clusters.iter().map(|c| c.x).collect();
assert!((cx[1] - cx[0] - 40.0).abs() < 1.0 && (cx[2] - cx[1] - 20.0).abs() < 1.0, "{fam}: {cx:?}");
assert!((s.glyphs[2].y - s.glyphs[1].y - 20.0).abs() < 1.0, "{fam}: glyphs follow the clusters");
// Rendered: the column is tall and narrow.
let (_, r) = e.render(&TextLayer { transform: Affine::translate(900.0, 30.0), ..vertical("縦書きテスト", &fam, 40.0) }, 72.0, PixelFormat::RGBA8);
assert!(r.rect.height() > 4 * r.rect.width(), "{fam}: {:?}", r.rect);
assert!(r.rect.x0 > 870 && r.rect.x1 < 930 && r.rect.y0 >= 28 && r.rect.y1 < 30 + 6 * 40 + 4, "{fam}: {:?}", r.rect);
}
#[test]
fn psd_tysh_round_trips_orientation_and_writing_direction() {
use crate::engine_data::Value as E;
for o in [Orientation::Vertical, Orientation::Horizontal] {
let t = TextLayer { orientation: o, ..vertical("縦書きAB", "Inter", 20.0) };
let bytes = crate::psd::build_tysh(&t, 72.0, None);
let back = crate::psd::text_layer_from_tysh(&bytes, 72.0).unwrap();
assert_eq!(back.orientation, o);
let parsed = crate::psd::parse_tysh(&bytes).unwrap();
let ed = crate::psd::engine_data(&parsed.text).unwrap();
let wd = ed.path(&["EngineDict", "Rendered", "Shapes", "WritingDirection"]).and_then(E::as_f64);
assert_eq!(wd, Some(if o == Orientation::Vertical { 2.0 } else { 0.0 }));
}
// A file without `Ornt` but with a vertical writing direction imports as vertical.
let t = TextLayer { orientation: Orientation::Vertical, ..vertical("ab", "Inter", 20.0) };
let mut parsed = crate::psd::parse_tysh(&crate::psd::build_tysh(&t, 72.0, None)).unwrap();
parsed.text.items.retain(|(k, _)| !k.is("Ornt"));
let back = crate::psd::text_layer_from_tysh(&crate::psd::write_tysh(&parsed), 72.0).unwrap();
assert_eq!(back.orientation, Orientation::Vertical);
}
/// Regular (400) from a family with only W3/W6 (Hiragino Mincho ProN) used W3 *and* asked for
/// synthetic bold, rendering a smeared bold serif. Only bold requests on non-bold faces embolden.
#[test]
fn nearest_face_is_not_synthetically_emboldened() {
use crate::layout::synthetic_bold;
assert!(!synthetic_bold(true, 400, 300.0), "regular from W3: use W3 as is");
assert!(!synthetic_bold(true, 500, 300.0));
assert!(synthetic_bold(true, 700, 400.0), "bold from a regular-only family");
assert!(!synthetic_bold(true, 700, 600.0), "W6 is bold enough");
assert!(!synthetic_bold(false, 900, 100.0), "matcher didn't ask");
// With an installed W3/W6 family (macOS Hiragino Mincho), the regular request uses W3, unemboldened.
#[cfg(target_os = "macos")]
{
let path = "/System/Library/Fonts/ヒラギノ明朝 ProN.ttc";
let Ok(bytes) = std::fs::read(path) else { return };
let mut e = TextEngine::new();
let fams = e.fonts.register_font_data(bytes);
let Some(fam) = fams.iter().find(|f| f.contains("Mincho")).cloned() else { return };
let l = e.layout(&vertical("縦書き", &fam, 30.0), 72.0);
assert!(!l.faces.is_empty());
assert!(l.faces.iter().all(|f| !f.embolden), "{fam}");
let bold = TextLayer {
runs: vec![TextRun { len: "縦".len(), style: CharStyle { font_family: fam.clone(), size_pt: 30.0, weight: 700, ..Default::default() } }],
..vertical("縦", &fam, 30.0)
};
// W6 serves the bold request: still no synthesis.
assert!(e.layout(&bold, 72.0).faces.iter().all(|f| !f.embolden));
}
}
#[test]
fn punctuation_squeeze_removes_half_em_between_marks() {
use crate::layout::{ClusterInfo, squeeze_punctuation};
// Line-space clusters of 40 px full-width cells: ト、「東」。
let text = "ト、「東」。";
let mut clusters = Vec::new();
let mut glyphs = Vec::new();
let mut x = 0.0;
for (i, (b, c)) in text.char_indices().enumerate() {
clusters.push(ClusterInfo { range: b..b + c.len_utf8(), x, advance: 40.0, line: 0, rtl: false });
glyphs.push(crate::PlacedGlyph { face: 0, id: i as u32 + 1, x, y: 0.0, style: 0, orient: GlyphOrient::Horizontal });
x += 40.0;
}
let cut = squeeze_punctuation(text, &mut clusters, &mut glyphs);
// 、「 and 」。 each lose half an em.
assert_eq!(cut, 40.0);
let xs: Vec<f32> = clusters.iter().map(|c| c.x).collect();
assert_eq!(xs, vec![0.0, 40.0, 60.0, 100.0, 140.0, 160.0]);
assert_eq!(glyphs.iter().map(|g| g.x).collect::<Vec<_>>(), xs);
// Opening + opening: the second bracket's leading half goes.
let text = "「「a";
let mut cl: Vec<ClusterInfo> = text
.char_indices()
.enumerate()
.map(|(i, (b, c))| ClusterInfo { range: b..b + c.len_utf8(), x: 40.0 * i as f32, advance: 40.0, line: 0, rtl: false })
.collect();
assert_eq!(squeeze_punctuation(text, &mut cl, &mut []), 20.0);
assert_eq!(cl.iter().map(|c| c.x).collect::<Vec<_>>(), vec![0.0, 20.0, 60.0]);
// Nothing to squeeze, and no panic on empty input or ranges outside the text.
let mut none = vec![ClusterInfo { range: 0..3, x: 0.0, advance: 40.0, line: 0, rtl: false }];
assert_eq!(squeeze_punctuation("東京", &mut none, &mut []), 0.0);
let mut bad = vec![ClusterInfo { range: 50..60, x: 0.0, advance: 1.0, line: 0, rtl: false }; 3];
assert_eq!(squeeze_punctuation("", &mut bad, &mut []), 0.0);
assert_eq!(squeeze_punctuation("", &mut [], &mut []), 0.0);
}
#[test]
fn hostile_vertical_input_does_not_panic() {
let mut e = TextEngine::new();
for text in ["", "\n\n", "\u{200F}א\u{0301}", "「」ー。、\r\n\u{FFFF}", "a\u{0000}b"] {
for shape in
[TextShape::Point, TextShape::Box { x: 0.0, y: 0.0, width: 0.0, height: 0.0 }, TextShape::Box { x: f32::NAN, y: 1e30, width: -5.0, height: 1.0 }]
{
let t = TextLayer { shape, ..vertical(text, "Inter", 12.0) };
let (l, _) = e.render(&t, 72.0, PixelFormat::RGBA8);
let _ = (l.bounds(), l.hit_test(1.0, 2.0), l.caret_segment(1));
}
}
}

View File

@ -4,7 +4,7 @@
//! for it (`canvas::ensure_texture`, the path without a GPU or when the GPU compositor falls back).
//!
//! ```sh
//! cargo run --release -p photocraft-ui-egui --example type_bench -- [--size 6000x4000] [--layers 50] [--steps 30] [--effects]
//! cargo run --release -p photocraft-ui-egui --example type_bench -- [--size 6000x4000] [--layers 50] [--steps 30] [--effects] [--typing]
//! ```
use std::time::Instant;
@ -21,6 +21,45 @@ fn median(mut v: Vec<f64>) -> f64 {
v.get(v.len() / 2).copied().unwrap_or(0.0)
}
/// `--typing`: per-keystroke cost of typing into a horizontal and a vertical (#199) Japanese type
/// layer: the edit command (re-layout and re-render), the Type tool's overlay layout and the CPU
/// canvas refresh, as the Type tool does it for each character.
fn typing(app: &mut PhotocraftApp, steps: usize) {
let text: Vec<char> = "縦書きのテキストは、「右から左へ」進みます。PhotoCraft 2026年ー".chars().collect();
for orient in ["horizontal", "vertical"] {
let r = app.run("type.create", json!({"x": 3000, "y": 200, "text": "", "size": 48, "font": "Hiragino Sans"})).expect("type.create");
let id = r["layer"].as_u64().unwrap_or(0);
app.run(&format!("type.orientation.{orient}"), json!({"layer": id})).expect("orientation");
app.sync_views();
let ctx = egui::Context::default();
let _ = photocraft_ui_egui::canvas::ensure_texture(app, &ctx, 0);
let (mut cmd, mut lay, mut canvas, mut total) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
for i in 0..steps {
let c = text.get(i % text.len()).copied().unwrap_or('あ');
let t0 = Instant::now();
app.run("type.edit", json!({"layer": id, "replace": {"start": i, "end": i, "text": c.to_string()}, "coalesce": format!("typing-{orient}")}))
.expect("type.edit");
let t1 = Instant::now();
let _ = photocraft_ui_egui::type_tool::layout(app, photocraft_doc::LayerId(id));
let t2 = Instant::now();
let _ = photocraft_ui_egui::canvas::ensure_texture(app, &ctx, 0);
let t3 = Instant::now();
cmd.push((t1 - t0).as_secs_f64() * 1e3);
lay.push((t2 - t1).as_secs_f64() * 1e3);
canvas.push((t3 - t2).as_secs_f64() * 1e3);
total.push((t3 - t0).as_secs_f64() * 1e3);
}
let worst = total.iter().copied().fold(0.0, f64::max);
println!(
"{orient:>10} typing (median of {steps} keystrokes): command {:.2} ms, overlay layout {:.2} ms, canvas refresh {:.2} ms, total {:.2} ms (worst {worst:.2} ms)",
median(cmd),
median(lay),
median(canvas),
median(total),
);
}
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let (w, h) =
@ -33,6 +72,10 @@ fn main() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default());
app.run("file.new", json!({"width": w, "height": h})).expect("new document");
if args.iter().any(|a| a == "--typing") {
typing(&mut app, steps);
return;
}
let t0 = Instant::now();
let mut ids = Vec::new();
for i in 0..n {

View File

@ -275,20 +275,42 @@ pub fn select_word(app: &mut PhotocraftApp) {
}
}
/// Caret on the neighbouring line (±1), keeping the x position.
fn vertical(app: &mut PhotocraftApp, id: LayerId, caret: usize, dir: i32) -> usize {
/// Caret on the neighbouring line (±1; a column in vertical type), keeping the position
/// along the line. Works in line space, so it serves both orientations.
fn line_step(app: &mut PhotocraftApp, id: LayerId, caret: usize, dir: i32) -> usize {
let Some((l, _, text)) = layout(app, id) else { return caret };
let (x, top, bottom) = l.caret(byte_of(&text, caret));
let h = (bottom - top).max(1.0);
let y = if dir < 0 { top - h * 0.5 } else { bottom + h * 0.5 };
let Some(b) = l.bounds() else { return caret };
let Some(b) = l.line_bounds() else { return caret };
if y < b[1] {
return 0;
}
if y > b[3] {
return text.chars().count();
}
char_of(&text, l.hit_test(x, y))
char_of(&text, l.hit_test_line(x, y))
}
fn is_vertical(app: &PhotocraftApp, id: LayerId) -> bool {
app.session.active().and_then(|s| text_layer(&s.doc, id)).is_some_and(|t| t.orientation == photocraft_doc::text::Orientation::Vertical)
}
/// Arrow keys follow the text flow: in vertical type ↑/↓ move along the column (previous/next
/// character) and ←/→ move to the next/previous column (columns advance right to left). Returns
/// the horizontal-type key with the same meaning.
pub(crate) fn flow_key(key: egui::Key, vertical: bool) -> egui::Key {
use egui::Key;
if !vertical {
return key;
}
match key {
Key::ArrowUp => Key::ArrowLeft,
Key::ArrowDown => Key::ArrowRight,
Key::ArrowLeft => Key::ArrowDown,
Key::ArrowRight => Key::ArrowUp,
k => k,
}
}
/// Line start / end for the caret's line.
@ -316,6 +338,7 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
}
ctx.request_repaint_after(std::time::Duration::from_millis(530)); // caret blink
let events = ctx.input(|i| i.events.clone());
let vertical_flow = is_vertical(app, id);
let mut handled = vec![false; events.len()];
for (k, ev) in events.iter().enumerate() {
let Some(ed) = app.ui.text_edit.clone() else { break };
@ -348,6 +371,7 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
}
egui::Event::Key { key, pressed: true, modifiers: m, .. } => {
use egui::Key;
let key = &flow_key(*key, vertical_flow);
match key {
Key::Backspace | Key::Delete => {
if a == b {
@ -388,7 +412,7 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
let to = if m.command {
if *key == Key::ArrowUp { 0 } else { n }
} else {
vertical(app, id, ed.caret, if *key == Key::ArrowUp { -1 } else { 1 })
line_step(app, id, ed.caret, if *key == Key::ArrowUp { -1 } else { 1 })
};
set(app, to, m.shift);
}
@ -445,15 +469,21 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX
let id = LayerId(ed.layer);
let Some((l, aff, text)) = layout(app, id) else { return };
let t = crate::theme::Tokens::get(painter.ctx());
let scr = |x: f32, y: f32| -> Pos2 {
// Text space → screen.
let scr_t = |x: f32, y: f32| -> Pos2 {
let p = aff.apply(Point::new(x as f64, y as f64));
xf.to_screen(p.x as f32, p.y as f32)
};
// Line space (lines, clusters, carets) → screen: vertical type turns it 90° clockwise.
let scr = |x: f32, y: f32| -> Pos2 {
let (x, y) = l.to_text(x, y);
scr_t(x, y)
};
// Tell the OS where the caret is: this is what enables the IME and places its candidate window.
{
let (x, top, bot) = l.caret(byte_of(&text, ed.caret));
let (x, top, bot) = if l.lines.is_empty() { (0.0, -(12.0 * l.px_per_pt.max(1.0)), 3.0) } else { (x, top, bot) };
let r = egui::Rect::from_two_pos(scr(x, top), scr(x, bot)).expand2(egui::vec2(1.0, 0.0));
let r = egui::Rect::from_two_pos(scr(x, top), scr(x, bot)).expand(1.0);
painter.ctx().output_mut(|o| {
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: false });
});
@ -463,7 +493,7 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX
let frame = Stroke::new(1.0, t.accent);
match shape {
Some(photocraft_doc::text::TextShape::Box { x, y, width, height }) => {
let c = [scr(x, y), scr(x + width, y), scr(x + width, y + height), scr(x, y + height)];
let c = [scr_t(x, y), scr_t(x + width, y), scr_t(x + width, y + height), scr_t(x, y + height)];
painter.add(egui::Shape::closed_line(c.to_vec(), frame));
let mids = [c[0].lerp(c[1], 0.5), c[1].lerp(c[2], 0.5), c[2].lerp(c[3], 0.5), c[3].lerp(c[0], 0.5)];
for p in c.iter().chain(mids.iter()) {
@ -474,7 +504,8 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX
}
_ => {
for ln in &l.lines {
let y = ln.baseline + ln.descent * 0.25;
// Horizontal: just under the baseline; vertical: the column's centre line.
let y = if l.vertical { ln.baseline } else { ln.baseline + ln.descent * 0.25 };
painter.line_segment([scr(ln.x0.min(0.0), y), scr(ln.x1.max(ln.x0 + 1.0), y)], Stroke::new(1.0, t.accent.gamma_multiply(0.8)));
}
}
@ -648,7 +679,15 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
Some((f, s, z, _)) => (f.clone(), if s.is_empty() { "Regular".into() } else { s.clone() }, *z),
None => (o.type_font.clone(), o.type_style.clone(), o.type_size),
};
let _ = crate::icons::button(ui, "text-cursor", 24.0, false, "Toggle text orientation");
if crate::icons::button(ui, "text-cursor", 24.0, false, "Toggle text orientation").clicked()
&& let Some((layer, _)) = target(app)
{
let to = if is_vertical(app, LayerId(layer)) { "horizontal" } else { "vertical" };
if let Err(e) = app.run(&format!("type.orientation.{to}"), json!({"layer": layer})) {
app.ui.status = e;
app.ui.status_error = true;
}
}
if font_picker(ui, &mut fam, 170.0) {
app.ui.tool_options.type_font = fam.clone();
let st = styles(&fam);

View File

@ -288,3 +288,72 @@ fn size_drag_is_live_and_one_history_step_per_drag() {
assert!(h.state_mut().session.undo());
assert_eq!(size_at(h.state(), id, 0), 20.0);
}
/// A point inside glyph `i` of vertical type: `f` of the way down its advance, right of the
/// column's centre line.
fn vglyph(h: &mut Harness<'static, PhotocraftApp>, id: LayerId, i: usize, f: f32) -> Pos2 {
let (l, _, text) = layout(h.state_mut(), id).unwrap();
assert!(l.vertical);
let b = text.char_indices().nth(i).unwrap().0;
let c = l.clusters.iter().find(|c| c.range.start == b).unwrap().clone();
let ln = &l.lines[c.line];
let (x, y) = l.to_text(c.x + c.advance * f, ln.baseline - ln.ascent * 0.35);
screen(h, id, x, y)
}
fn key(h: &mut Harness<'static, PhotocraftApp>, k: egui::Key) {
h.event(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(1);
}
/// Vertical type (#199): clicks and drags land on the glyph under the pointer down the column,
/// and the arrow keys follow the vertical flow (↓ next character, ← next column).
#[test]
fn vertical_type_caret_selection_and_arrows_follow_the_columns() {
let rotated = Affine { m: [0.94, 0.342, -0.342, 0.94, 900.0, 200.0] };
for ppp in [1.0, 2.0] {
for tf in [None, Some(rotated)] {
let mut app = new_app();
let id = LayerId(app.run("type.create", json!({"text": "HOHOHO\nHOHO", "size": 60, "x": 800, "y": 120})).unwrap()["layer"].as_u64().unwrap());
app.run("type.orientation.vertical", json!({"layer": id.0})).unwrap();
// Snapping would pull the pointer onto the layer's edges and centre lines.
app.ui.extras.snap = false;
if let Some(a) = tf {
app.run("type.edit", json!({"layer": id.0, "transform": a.m})).unwrap();
}
let mut h = harness(ppp, app);
let (l, aff, _) = layout(h.state_mut(), id).unwrap();
let b = l.bounds().unwrap();
assert!(b[3] - b[1] > b[2] - b[0], "vertical bounds {b:?}");
let m = aff.apply(Point::new(f64::from(b[0] + b[2]) / 2.0, f64::from(b[1] + b[3]) / 2.0));
let v = &mut h.state_mut().ui.views[0];
(v.zoom, v.center, v.fit_pending) = (0.5, [m.x as f32, m.y as f32], false);
h.run_steps(2);
let ctx = format!("ppp {ppp} transformed {}", tf.is_some());
// Upper part of glyph 2 → caret before it; lower part of glyph 4 → after it.
let p = vglyph(&mut h, id, 2, 0.2);
click(&mut h, p);
assert_eq!(selection(&h), (2, 2), "{ctx}");
let p = vglyph(&mut h, id, 4, 0.8);
click(&mut h, p);
assert_eq!(selection(&h), (5, 5), "{ctx}");
// Drag down the column from glyph 1 into the second column's glyph 2 (char 9).
let (a, b) = (vglyph(&mut h, id, 1, 0.25), vglyph(&mut h, id, 9, 0.75));
drag(&mut h, a, b);
assert_eq!(selection(&h), (1, 10), "{ctx}: drag");
// Arrows: ↓ next char, ↑ previous, ← next column (same position), → back.
let p = vglyph(&mut h, id, 1, 0.2);
click(&mut h, p);
assert_eq!(selection(&h), (1, 1), "{ctx}");
key(&mut h, egui::Key::ArrowDown);
assert_eq!(selection(&h).1, 2, "{ctx}: ↓");
key(&mut h, egui::Key::ArrowUp);
assert_eq!(selection(&h).1, 1, "{ctx}: ↑");
key(&mut h, egui::Key::ArrowLeft);
assert_eq!(selection(&h).1, 8, "{ctx}: ← moves to the next column");
key(&mut h, egui::Key::ArrowRight);
assert_eq!(selection(&h).1, 1, "{ctx}: → moves back");
}
}
assert_eq!(super::flow_key(egui::Key::ArrowUp, false), egui::Key::ArrowUp);
}

View File

@ -32,7 +32,7 @@ just presence.
| Paint / brush engine, retouch (clone, healing) | ~85% | Solid; no Mixer Brush depth, no Content-Aware healing |
| Adjustments + adjustment layers | ~90% | All kinds present; Camera Raw depth and a few toe-curve models approximate |
| Filters (incl. Filter Gallery ×47, Liquify, blurs, render) | ~88% | Gallery looks are approximations; no Neural Filters |
| Type / text engine, character & paragraph styles, glyphs | ~90% | Vertical type and OpenType alternates thin |
| Type / text engine, character & paragraph styles, glyphs | ~90% | Vertical type laid out and rendered (#199; no mojikumi or upright-Roman option yet); OpenType alternates thin |
| Color management (ICC v2/v4, intents, soft-proof) | ~90% | Own pure-Rust CMS; no Adobe CMYK profiles (synthetic) |
| File I/O: PSD, native .pcraft, flats (png/jpg/tiff/webp/exr/…) | ~85% | PSD oracle ~65% pixel-exact; most structure round-trips |
| Transform, warp, puppet, perspective, liquify | ~88% | No Face-Aware Liquify (needs a landmark model) |

View File

@ -66,7 +66,7 @@ Confidence: moderate — the next users of 0.2.x will move these numbers either
| Smart filters / text / effect shapes in PSDs | Not measured: the corpus barely contains them | unknown (probably low–medium) | A Photoshop-authored reference set is being built (`corpus/photoshop`). |
| Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. |
| UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157) | low–medium | Needs recurring visual QA with realistic documents. |
| Tools | ~20 Photoshop tools missing: Pencil, Mixer Brush (tool), Patch, Content-Aware Move, Red Eye, Pattern Stamp, Art History Brush, Freeform/Curvature Pen, anchor tools, Direct Selection, Magnetic Lasso, single row/column marquee, Color Sampler, Perspective Crop, Rotate View, vertical type and type masks, Frame | low–medium | Magic/Background Eraser added; live gradients in progress (#180). |
| Tools | ~20 Photoshop tools missing: Pencil, Mixer Brush (tool), Patch, Content-Aware Move, Red Eye, Pattern Stamp, Art History Brush, Freeform/Curvature Pen, anchor tools, Direct Selection, Magnetic Lasso, single row/column marquee, Color Sampler, Perspective Crop, Rotate View, the Vertical Type tool (vertical layout itself landed, #199: toggle via Type › Orientation) and type masks, Frame | low–medium | Magic/Background Eraser added; live gradients in progress (#180). |
| Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pressure on Windows and web only | medium | macOS/Linux pressure blocked on the windowing layer (#79). |
| Text / typography | Engine works; caret placement and size editing fixed; OpenType features, text-on-path editing, composer parity partial | medium-low | Measure with the Photoshop-authored set. |
| Colour management | Colour-managed canvas (document → monitor), embedded CMYK profiles, linear EXR/HDR, 16-bit float canvas | medium-high | Monitor profile follows only at launch. |