This commit is contained in:
parent
5a5faccd4a
commit
ae80df2eb4
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -89,3 +89,5 @@ pub fn shared() -> &'static std::sync::Mutex<TextEngine> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod vertical_tests;
|
||||
|
|
|
|||
|
|
@ -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]))])),
|
||||
]),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
Loading…
Reference in New Issue