i18n: add Japanese menus and initial vertical type tools

This commit is contained in:
midasdf committed 2026-10-06 16:55:47 +09:00
1 parent 2e2bb33909
commit 44b2b984ed
25 files changed
+537 -39

No files matched your search

+2
View File
@@ -46,6 +46,8 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/fonts/OFL-JetBrainsMono.txt` | (licence text) | upstream project | — | |
| `assets/fonts/OFL-SourceSans3.txt` | (licence text) | upstream project | — | |
| `assets/fonts/OFL-SourceSerif4.txt` | (licence text) | upstream project | — | |
| `assets/fonts/ShipporiMincho-Regular.ttf` | The Shippori Mincho Project Authors (FONTDASU) | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/shipporimincho | OFL-1.1 (`assets/fonts/OFL-ShipporiMincho.txt`) | Japanese UI/document fallback; SHA-256 `769b5269f0f9bc6534b352c0e6bd856a566e03ff788f107191c2d835863570b2` |
| `assets/fonts/OFL-ShipporiMincho.txt` | The Shippori Mincho Project Authors (FONTDASU) | https://github.com/google/fonts/blob/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/shipporimincho/OFL.txt | OFL-1.1 | Licence for Shippori Mincho |
| `assets/icons/LICENSE-lucide.txt` | (licence text) | upstream project | — | |
| `assets/icons/align-center-horizontal.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/align-center-vertical.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
+4
View File
@@ -29,3 +29,7 @@ based in part on the work of the Independent JPEG Group.
WebAssembly plug-ins run in the wasmi interpreter (MIT OR Apache-2.0); its wasmparser dependency is
under the Apache License 2.0 with the LLVM exception.
Shippori Mincho: Copyright 2021 The Shippori Mincho Project Authors
(https://github.com/fontdasu/ShipporiMincho). SIL Open Font License 1.1;
see assets/fonts/OFL-ShipporiMincho.txt.
+12
View File
@@ -221,3 +221,15 @@ command, gesture and dialog drivable by agents (MCP, CLI, control channel).
## How to update this file
After each milestone task lands, update the status column and remaining estimates, and move items into "Shipped so far". Keep estimates honest: re-derive them from what remains, never from wishful velocity.
### Interface localization
The VectorCraft > Language submenu switches English/Japanese and persists in UI state (`app.language {lang: en|ja}`). Both macOS native and in-window menus translate core labels; the native menu refreshes after a language change. Bundled OFL Shippori Mincho provides Japanese UI/document glyph fallback without system fonts. Untranslated labels use English. Broader dialog/panel translation and vertical/CJK composition remain open.
Vertical point/area/path type tools now create persisted vertical text. Type > Orientation
switches existing objects. Point/area text flows down columns progressing right to left, with
upright Japanese glyphs and vert/vrt2 alternates. Hit testing, caret/selection and arrow keys
use the writing axes. Vertical path type turns glyphs across the baseline path. SVG exports
vertical text as outlines to preserve appearance; native documents retain editable text.
This is initial vertical support, without kinsoku, ruby, tate-chu-yoko, vertical font metrics
or full mixed-script Unicode vertical orientation.
+18 -8
View File
@@ -18,6 +18,7 @@ enum Handle {
}
pub struct NativeMenu {
language: vectorcraft_ui_egui::i18n::Language,
_menu: Menu,
items: HashMap<String, (String, Value, Handle, String)>,
last_refresh: f64,
@@ -47,7 +48,7 @@ impl NativeMenu {
let mut items = HashMap::new();
let mut counter = 0usize;
for (title, entries) in menus::menu_tree() {
let sub = Submenu::new(title, true);
let sub = Submenu::new(app.ui.language.tr(title), true);
build(app, &sub, &entries, &mut items, &mut counter);
let _ = menu.append(&sub);
}
@@ -58,7 +59,15 @@ impl NativeMenu {
.filter(|(_, p, _, c)| (p.is_null() || p.as_object().is_some_and(|o| o.is_empty())) && menus::shortcut_of(c).and_then(accel).is_some())
.map(|(_, _, _, c)| c.clone())
.collect();
Self { _menu: menu, items, last_refresh: 0.0, generation, slots: menus::listed_slots(app), plugins: menus::plugin_revision() }
Self {
_menu: menu,
items,
last_refresh: 0.0,
generation,
slots: menus::listed_slots(app),
plugins: menus::plugin_revision(),
language: app.ui.language,
}
}
/// Dispatch clicked items and refresh state.
@@ -79,6 +88,7 @@ impl NativeMenu {
if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation
|| menus::listed_slots(app) != self.slots
|| menus::plugin_revision() != self.plugins
|| app.ui.language != self.language
{
*self = NativeMenu::install(app);
return;
@@ -111,7 +121,7 @@ impl NativeMenu {
"view.cornerWidget" | "view.textThreads" | "view.gradientAnnotator" | "effect.last" | "type.hiddenCharacters"
)
{
i.set_text(menus::dynamic_label(app, cmd, ""));
i.set_text(app.ui.language.tr(&menus::dynamic_label(app, cmd, "")));
}
}
Handle::Check(c) => {
@@ -136,13 +146,13 @@ fn build(
let _ = parent.append(&PredefinedMenuItem::separator());
}
Item::Header(h) => {
let _ = parent.append(&MenuItem::new(*h, false, None));
let _ = parent.append(&MenuItem::new(app.ui.language.tr(h), false, None));
}
Item::Todo(label, sc) => {
let _ = parent.append(&MenuItem::new(*label, false, accel(sc)));
let _ = parent.append(&MenuItem::new(app.ui.language.tr(label), false, accel(sc)));
}
Item::Sub(label, children) => {
let sub = Submenu::new(*label, true);
let sub = Submenu::new(app.ui.language.tr(label), true);
build(app, &sub, children, items, counter);
let _ = parent.append(&sub);
}
@@ -153,11 +163,11 @@ fn build(
let id = format!("dc{counter}");
let sc = if params.is_null() { menus::shortcut_of(cmd).and_then(accel) } else { None };
let handle = if menus::checked(app, cmd, params).is_some() {
let c = CheckMenuItem::with_id(id.clone(), *label, true, false, sc);
let c = CheckMenuItem::with_id(id.clone(), app.ui.language.tr(label), true, false, sc);
let _ = parent.append(&c);
Handle::Check(c)
} else {
let i = MenuItem::with_id(id.clone(), *label, true, sc);
let i = MenuItem::with_id(id.clone(), app.ui.language.tr(label), true, sc);
let _ = parent.append(&i);
Handle::Plain(i)
};
+93
View File
@@ -0,0 +1,93 @@
Copyright 2021 The Shippori Mincho Project Authors (https://github.com/fontdasu/ShipporiMincho)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+4
View File
@@ -414,6 +414,9 @@ pub enum TextKind {
/// A text object. `runs` split into paragraphs at `\n`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextObject {
/// Top-to-bottom, right-to-left writing. Defaults to horizontal for old documents.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub vertical: bool,
pub kind: TextKind,
/// Maps text space (origin = first baseline start for point type) to the document.
pub xf: Affine,
@@ -437,6 +440,7 @@ pub struct TextObject {
impl TextObject {
pub fn point(origin: Point, text: &str, style: CharStyle) -> Self {
Self {
vertical: false,
kind: TextKind::Point,
xf: Affine::translate(origin.to_vec2()),
runs: vec![TextRun { text: text.into(), style }],
+2 -1
View File
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Text",
[],
None,
"{x, y, text, size?: pt, font?: family, style?, color?, area?: {width, height}} → {id}",
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height}} → {id}",
has_doc,
text_create
),
@@ -401,6 +401,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
let y = f64_req(p, "y", "text.create")?;
let text = str_param(p, "text").unwrap_or("");
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p));
t.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false);
if let Some(a) = p.get("area") {
let w = f64_or(a, "width", 200.0);
let h = f64_or(a, "height", 100.0);
+3 -1
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Area / Path Type",
[],
None,
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", at?: [x, y] (on-path start: nearest point), size?, font?} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
has_doc,
create_in_path
),
@@ -368,6 +368,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
};
let kind = if on_path { TextKind::OnPath { path: path.clone(), start } } else { TextKind::Area { frame: path.clone() } };
let mut t = TextObject {
vertical: p.get("vertical").and_then(Value::as_bool).unwrap_or(false),
kind,
xf: Affine::IDENTITY,
runs: vec![TextRun { text, style }],
@@ -404,6 +405,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
let head = edit::slice_runs(&t.runs, 0, end);
let measure = |tr: f64| {
let mut h = TextObject {
vertical: t.vertical,
kind: TextKind::Point,
xf: Affine::IDENTITY,
runs: head.clone(),
+27
View File
@@ -13,6 +13,18 @@ use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("type.orientation.vertical", "Vertical", ["Type", "Orientation"], None, "{ids?|id?} set vertical writing", has_selection, |s, p| {
orientation(s, p, true)
}),
cmd!(
"type.orientation.horizontal",
"Horizontal",
["Type", "Orientation"],
None,
"{ids?|id?} set horizontal writing",
has_selection,
|s, p| orientation(s, p, false)
),
cmd!(
"type.createOutlines",
"Create Outlines",
@@ -61,6 +73,21 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result<Value> {
let ids = text_targets(s, p, "Text Orientation")?;
s.edit("Text Orientation", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else {
return Err(EngineError::NoNode(*id));
};
t.vertical = vertical;
refresh_bounds(t);
}
Ok(())
})?;
Ok(json!({"vertical": vertical, "ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}))
}
/// Recompute the layout bounds cache after a text edit.
pub(crate) fn refresh_bounds(t: &mut TextObject) {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
+19
View File
@@ -378,3 +378,22 @@ fn type_tool_state_does_not_leak_across_documents() {
assert_eq!(obj(&s, id).plain_text(), "First");
assert!(!s.in_interaction());
}
#[test]
fn vertical_text_creation_orientation_and_persistence() {
let mut s = session();
let r = s.execute("text.create", &json!({"x":100,"y":100,"text":"日本語", "vertical":true})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let t = obj(&s, id);
assert!(t.vertical);
let round: TextObject = serde_json::from_value(serde_json::to_value(&t).unwrap()).unwrap();
assert!(round.vertical);
s.execute("type.orientation.horizontal", &json!({"id":id.0})).unwrap();
assert!(!obj(&s, id).vertical);
s.execute("edit.undo", &json!({})).unwrap();
assert!(obj(&s, id).vertical);
let old = serde_json::to_value(TextObject::point(Point::ZERO, "old", Default::default())).unwrap();
assert!(old.get("vertical").is_none());
let old: TextObject = serde_json::from_value(old).unwrap();
assert!(!old.vertical);
}
+1 -1
View File
@@ -1576,7 +1576,7 @@ impl Writer<'_> {
/// as the canvas paints them, the object's own fills and strokes on the glyph outlines: those
/// below the Characters row under the characters, the others over them.
fn text_node(&mut self, n: &Node, t: &TextObject) {
let chars = |w: &mut Self, n: &Node| if w.opts.outline_text { w.text_outlines(n, t) } else { w.text(n, t) };
let chars = |w: &mut Self, n: &Node| if w.opts.outline_text || t.vertical { w.text_outlines(n, t) } else { w.text(n, t) };
if !n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()) {
return chars(self, n);
}
+7
View File
@@ -7,6 +7,8 @@ use vectorcraft_doc::CharStyle;
/// by the character style (`kerning`, `all_caps`, `tracking`); the rest are layout-wide options.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OtFeatures {
/// Vertical glyph alternates (vert/vrt2), set by the writing direction.
pub vertical: bool,
/// Standard ligatures (`liga`, `clig`). Suppressed automatically when tracking is non-zero.
pub ligatures: bool,
/// Contextual alternates (`calt`).
@@ -30,6 +32,7 @@ pub struct OtFeatures {
impl Default for OtFeatures {
fn default() -> Self {
Self {
vertical: false,
ligatures: true,
contextual: true,
discretionary_ligatures: false,
@@ -138,6 +141,10 @@ impl OtFeatures {
v.push(f(tag, true));
}
}
if self.vertical {
v.push(f(b"vert", true));
v.push(f(b"vrt2", true));
}
v
}
}
+4
View File
@@ -16,6 +16,9 @@ use skrifa::{GlyphId, MetadataProvider};
/// The family used when a requested family is unknown (Illustrator's Myriad Pro analogue).
pub const FALLBACK_FAMILY: &str = "Source Sans 3";
/// Bundled Japanese font bytes, shared with UI glyph fallback.
pub static SHIPPORI_MINCHO_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/ShipporiMincho-Regular.ttf");
static BUNDLED: &[&[u8]] = &[
include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"),
include_bytes!("../../../assets/fonts/SourceSans3-Semibold.ttf"),
@@ -26,6 +29,7 @@ static BUNDLED: &[&[u8]] = &[
include_bytes!("../../../assets/fonts/Inter-Medium.ttf"),
include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"),
include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"),
SHIPPORI_MINCHO_REGULAR,
];
enum FontBytes {
+49 -4
View File
@@ -14,6 +14,8 @@ use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, PositionedGlyph, T
const EPS: f64 = 1e-6;
struct Ctx<'a> {
vertical: bool,
on_path: bool,
db: &'a FontDb,
text: &'a str,
runs: Vec<(Range<usize>, &'a CharStyle)>,
@@ -37,7 +39,22 @@ impl Ctx<'_> {
fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) {
let src = self.db.outline(&g.face, g.gid);
let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy);
let m = pre * local;
let mut m = pre * local;
let mut origin = origin;
let mut angle = angle;
if self.vertical {
let writing = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]);
if self.on_path {
// Vertical path type keeps the baseline path and turns each glyph across it.
m = Affine::translate(origin.to_vec2()) * Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::translate(-origin.to_vec2()) * m;
} else {
let physical = writing * origin;
let upright = matches!(g.ch as u32, 0x3000..=0x30ff | 0x3400..=0x9fff | 0xf900..=0xfaff | 0xfe10..=0xfe4f | 0xff01..=0xff60 | 0x20000..=0x3134f);
m = if upright { Affine::translate((physical - origin) + Vec2::new(-g.descent, g.ascent)) * m } else { writing * m };
origin = physical;
angle += std::f64::consts::FRAC_PI_2;
}
}
// Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef).
let outline = if src.elements().is_empty() || g.is_soft_hyphen() || g.ch.is_control() {
BezPath::new()
@@ -99,16 +116,43 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay
}
}
paras.push(s..text.len());
let mut cx = Ctx { db, text: &text, runs, default: CharStyle::default(), opts, out: TextLayout::default() };
let mut vertical_opts = opts.clone();
vertical_opts.features.vertical = t.vertical;
let opts = &vertical_opts;
let is_on_path = matches!(&t.kind, TextKind::OnPath { .. });
let mut cx = Ctx {
vertical: t.vertical,
on_path: is_on_path,
db,
text: &text,
runs,
default: CharStyle::default(),
opts,
out: TextLayout { vertical: t.vertical && !is_on_path, ..Default::default() },
};
match &t.kind {
TextKind::Point => flow(&mut cx, &paras, &t.para, None),
TextKind::Area { frame } => {
let regions = Region::cells(&frame.to_bezpath(), opts, &t.wrap);
let inverse = Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, 0.0]);
let frame = if t.vertical { frame.transformed(inverse) } else { frame.clone() };
let mut wrap = t.wrap.clone();
if t.vertical {
for w in &mut wrap {
w.path.transform(inverse);
}
}
let regions = Region::cells(&frame.to_bezpath(), opts, &wrap);
cx.out.frames = regions.iter().map(|r| r.cell).collect();
flow(&mut cx, &paras, &t.para, Some(&regions));
}
TextKind::OnPath { path, start } => on_path(&mut cx, &paras, &t.para, &path.to_bezpath(), *start, path.is_closed(), t.path_effect),
}
if cx.out.vertical {
let writing = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]);
for frame in &mut cx.out.frames {
*frame = writing.transform_rect_bbox(*frame);
}
}
finish_bounds(&mut cx.out);
cx.out
}
@@ -123,7 +167,8 @@ fn finish_bounds(out: &mut TextLayout) {
}
if !out.on_path {
for l in &out.lines {
add(Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent));
let r = Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent);
add(if out.vertical { Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]).transform_rect_bbox(r) } else { r });
}
}
out.bounds = b.unwrap_or_default();
+17 -7
View File
@@ -20,7 +20,7 @@ mod shape;
pub mod thread;
pub use features::OtFeatures;
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, style_weight, system_font_dirs};
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, SHIPPORI_MINCHO_REGULAR, style_weight, system_font_dirs};
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
pub use layout::{layout, layout_with};
pub use vectorcraft_doc::TextObject;
@@ -124,6 +124,8 @@ pub struct LineInfo {
#[derive(Clone, Debug, Default)]
pub struct TextLayout {
/// Lines retain inline/block coordinates; glyph geometry is in physical text space.
pub vertical: bool,
pub glyphs: Vec<PositionedGlyph>,
pub lines: Vec<LineInfo>,
/// Ink/advance bounds in text space.
@@ -138,6 +140,13 @@ pub struct TextLayout {
}
impl TextLayout {
/// Convert physical text coordinates to inline/block coordinates.
pub fn logical_point(&self, p: Point) -> Point {
if self.vertical { Point::new(p.y, -p.x) } else { p }
}
pub fn physical_point(&self, p: Point) -> Point {
if self.vertical { Point::new(-p.y, p.x) } else { p }
}
/// All glyph outlines combined (e.g. for Create Outlines).
pub fn to_bezpath(&self) -> BezPath {
let mut p = BezPath::new();
@@ -192,7 +201,7 @@ pub fn caret_position(layout: &TextLayout, byte: usize) -> (Point, Point) {
} else if let Some(g) = glyphs.last() {
(g.origin + dir(g.angle) * g.advance, g.angle)
} else {
(Point::new(line.x0, line.baseline), 0.0)
(layout.physical_point(Point::new(line.x0, line.baseline)), if layout.vertical { std::f64::consts::FRAC_PI_2 } else { 0.0 })
};
// Up vector in y-down space, rotated with the baseline.
let d = dir(angle);
@@ -214,6 +223,7 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize {
None => layout.lines.first().map_or(0, |l| l.start),
};
}
let p = layout.logical_point(p);
// Nearest line band (vertical distance first, then distance to the line's column).
let dist = |l: &LineInfo| {
let dy = if p.y < l.baseline - l.ascent {
@@ -247,7 +257,7 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize {
pub fn byte_in_line(layout: &TextLayout, li: usize, x: f64) -> usize {
let Some(line) = layout.lines.get(li) else { return 0 };
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
if let Some(g) = glyphs.iter().find(|g| x < g.origin.x + g.advance * 0.5) {
if let Some(g) = glyphs.iter().find(|g| x < layout.logical_point(g.origin).x + g.advance * 0.5) {
return g.byte;
}
line_end(layout, li)
@@ -281,9 +291,9 @@ fn x_in_line(layout: &TextLayout, li: usize, byte: usize) -> f64 {
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
if let Some(g) = glyphs.iter().find(|g| g.byte + g.len > byte) {
let frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 };
return g.origin.x + g.advance * frac;
return layout.logical_point(g.origin).x + g.advance * frac;
}
glyphs.last().map_or(line.x0, |g| g.origin.x + g.advance)
glyphs.last().map_or(line.x0, |g| layout.logical_point(g.origin).x + g.advance)
}
/// Move the caret `delta` lines up (negative) or down, keeping horizontal position `goal_x`
@@ -345,14 +355,14 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4
let mut xb = x_in_line(layout, li, b.min(l.end));
if b > l.end {
// The selection continues past the line: include the line break / trailing space.
let last = layout.glyphs[l.glyph_start..l.glyph_end].last().map_or(l.x1, |g| g.origin.x + g.advance);
let last = layout.glyphs[l.glyph_start..l.glyph_end].last().map_or(l.x1, |g| layout.logical_point(g.origin).x + g.advance);
xb = last.max(l.x1) + (l.ascent + l.descent) * 0.25;
}
if xb - xa <= 1e-9 {
continue;
}
let (t, bt) = (l.baseline - l.ascent, l.baseline + l.descent);
out.push([Point::new(xa, t), Point::new(xb, t), Point::new(xb, bt), Point::new(xa, bt)]);
out.push([Point::new(xa, t), Point::new(xb, t), Point::new(xb, bt), Point::new(xa, bt)].map(|p| layout.physical_point(p)));
}
out
}
+31
View File
@@ -421,3 +421,34 @@ fn tab_stops_position_text() {
p.para.tabs = vec![stop(50.0, TabAlign::Left)];
assert!((x_of(&p, 4) - 72.0).abs() < 1e-6);
}
#[test]
fn vertical_japanese_columns_have_upright_ink_and_edit_geometry() {
let mut t = point("日本語\n縦書き", style(30.0));
t.vertical = true;
let l = layout(db(), &t);
assert!(l.vertical);
assert_eq!(l.lines.len(), 2);
assert!(l.glyphs.iter().all(|g| g.gid != 0 && !g.outline.elements().is_empty()));
assert!(l.glyphs[3].origin.x < l.glyphs[0].origin.x);
assert!(l.glyphs[1].origin.y > l.glyphs[0].origin.y);
for g in &l.glyphs {
let p = g.origin + dir(g.angle) * (g.advance * 0.1);
assert_eq!(hit_byte(&l, p), g.byte);
let (a, b) = caret_position(&l, g.byte);
assert!((a.y - b.y).abs() < 1e-6);
assert!(a.x > b.x);
}
let selection = selection_quads(&l, 0, "日本語".len());
assert_eq!(selection.len(), 1);
}
#[test]
fn vertical_area_type_wraps_into_columns_inside_the_frame() {
let mut t = area("日本語日本語日本語", style(20.0), Rect::new(0.0, 0.0, 100.0, 65.0), Justify::Left);
t.vertical = true;
let l = layout(db(), &t);
assert!(l.lines.len() > 1);
assert!(l.glyphs.iter().all(|g| g.origin.x >= 0.0 && g.origin.x <= 100.0 && g.origin.y >= 0.0 && g.origin.y <= 65.0));
assert!(l.glyphs[l.lines[1].glyph_start].origin.x < l.glyphs[0].origin.x);
}
+1 -1
View File
@@ -414,7 +414,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
"directSelection" => Box::new(direct::DirectSelectionTool::new(false)),
"groupSelection" => Box::new(direct::DirectSelectionTool::new(true)),
"pen" => Box::new(pen::PenTool::default()),
"type" | "areaType" | "typeOnPath" => Box::new(text::TypeTool::new(id)),
"type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath" => Box::new(text::TypeTool::new(id)),
"rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" | "lineSegment" => Box::new(shape::ShapeTool::new(id)),
// Not in the toolbar: `file.place.queue` loads it.
"place" => Box::new(place::PlaceTool::default()),
+29 -10
View File
@@ -40,6 +40,7 @@ struct Typing {
#[derive(Default)]
pub struct TypeTool {
vertical: bool,
mode: Mode,
/// Text object being edited.
editing: Option<NodeId>,
@@ -65,11 +66,11 @@ pub struct TypeTool {
impl TypeTool {
pub fn new(id: &str) -> Self {
let mode = match id {
"areaType" => Mode::Area,
"typeOnPath" => Mode::OnPath,
"areaType" | "verticalAreaType" => Mode::Area,
"typeOnPath" | "verticalTypeOnPath" => Mode::OnPath,
_ => Mode::Type,
};
Self { mode, ..Default::default() }
Self { mode, vertical: id.starts_with("vertical"), ..Default::default() }
}
fn text<'a>(cx: &'a ToolContext, id: NodeId) -> Option<&'a TextObject> {
@@ -236,16 +237,20 @@ impl TypeTool {
let on_path = self.mode == Mode::OnPath;
if let Some(pid) = Self::path_at(cx, start, !on_path) {
let mode = if on_path { "onPath" } else { "area" };
out.push(Action::Exec("text.createInPath".into(), json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y]})));
out.push(Action::Exec(
"text.createInPath".into(),
json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y], "vertical": self.vertical}),
));
out.push(Action::Notify("text.editNew".into()));
return out;
}
}
let area = drag.map(|d| Rect::from_points(start, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0));
let params = match area {
let mut params = match area {
Some(r) => json!({"x": r.x0, "y": r.y0 + 12.0, "text": "", "area": {"width": r.width(), "height": r.height()}}),
None => json!({"x": start.x, "y": start.y, "text": ""}),
};
params["vertical"] = json!(self.vertical);
out.push(Action::Exec("text.create".into(), params));
out.push(Action::Notify("text.editNew".into()));
out
@@ -294,10 +299,13 @@ fn common_affixes(a: &str, b: &str) -> (usize, usize) {
impl Tool for TypeTool {
fn id(&self) -> &'static str {
match self.mode {
Mode::Type => "type",
Mode::Area => "areaType",
Mode::OnPath => "typeOnPath",
match (self.mode, self.vertical) {
(Mode::Type, false) => "type",
(Mode::Type, true) => "verticalType",
(Mode::Area, false) => "areaType",
(Mode::Area, true) => "verticalAreaType",
(Mode::OnPath, false) => "typeOnPath",
(Mode::OnPath, true) => "verticalTypeOnPath",
}
}
fn busy(&self) -> bool {
@@ -397,6 +405,17 @@ impl Tool for TypeTool {
let (a, b) = self.sel();
let word = mods.cmd || mods.alt;
let lay = self.layout(&t);
let key = if lay.vertical {
match key {
ToolKey::Up => ToolKey::Left,
ToolKey::Down => ToolKey::Right,
ToolKey::Right => ToolKey::Up,
ToolKey::Left => ToolKey::Down,
other => other,
}
} else {
key
};
let vertical = matches!(key, ToolKey::Up | ToolKey::Down);
if !vertical {
self.goal_x = None;
@@ -437,7 +456,7 @@ impl Tool for TypeTool {
ToolKey::Up | ToolKey::Down => {
let d = if key == ToolKey::Up { -1 } else { 1 };
let from = if a != b && !mods.shift { if d < 0 { a } else { b } } else { self.caret };
let x = self.goal_x.unwrap_or_else(|| vectorcraft_text::caret_position(&lay, from).0.x);
let x = self.goal_x.unwrap_or_else(|| lay.logical_point(vectorcraft_text::caret_position(&lay, from).0).x);
let to = if mods.cmd {
// Cmd+Up/Down: paragraph start / end.
let p = edit::paragraph_at(&text, from);
+9
View File
@@ -218,3 +218,12 @@ fn area_and_path_tools_convert_clicked_paths() {
let acts = tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 150.0, 100.0));
assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, p) if cmd == "text.createInPath" && p["mode"] == "onPath")), "{acts:?}");
}
#[test]
fn vertical_tools_preserve_their_ids_and_create_vertical_text() {
for id in ["verticalType", "verticalAreaType", "verticalTypeOnPath"] {
let tool = TypeTool::new(id);
assert_eq!(tool.id(), id);
assert!(tool.vertical);
}
}
+171
View File
@@ -0,0 +1,171 @@
//! Interface translations. Command ids, document text and file names remain stable.
//! Untranslated labels fall back to English so coverage can grow incrementally.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Language {
#[default]
En,
Ja,
}
impl Language {
pub const ALL: [Self; 2] = [Self::En, Self::Ja];
pub fn name(self) -> &'static str {
match self {
Self::En => "English",
Self::Ja => "日本語",
}
}
pub fn parse(code: &str) -> Option<Self> {
match code {
"en" => Some(Self::En),
"ja" => Some(Self::Ja),
_ => None,
}
}
pub fn tr(self, text: &str) -> &str {
if self == Self::Ja
&& let Some((_, japanese)) = JAPANESE.iter().find(|(english, _)| *english == text)
{
return japanese;
}
text
}
}
const JAPANESE: &[(&str, &str)] = &[
("Object", "オブジェクト"),
("Effect", "効果"),
("Settings…", "環境設定…"),
("Image", "画像"),
("Layer", "レイヤー"),
("Type", "書式"),
("Select", "選択"),
("Filter", "フィルター"),
("Window", "ウィンドウ"),
("Language", "表示言語"),
("Save As…", "別名で保存…"),
("Exit", "終了"),
("New…", "新規…"),
("New", "新規"),
("Horizontal", "横書き"),
("Vertical", "縦書き"),
("Orientation", "組み方向"),
("Type Orientation", "組み方向"),
("Layers", "レイヤー"),
("History", "履歴"),
("Properties", "プロパティ"),
("Color", "カラー"),
("Brush Settings", "ブラシ設定"),
("Tools", "ツール"),
("Options", "オプション"),
("Zoom In", "ズームイン"),
("Zoom Out", "ズームアウト"),
("Fit on Screen", "画面に合わせる"),
("Copy", "コピー"),
("Cut", "切り取り"),
("Paste", "貼り付け"),
("Select All", "すべて選択"),
("Deselect", "選択を解除"),
("Export", "書き出し"),
("Export As…", "形式を指定して書き出し…"),
("Search…", "検索…"),
("Theme", "テーマ"),
("Menu", "メニュー"),
("File", "ファイル"),
("Edit", "編集"),
("Pages", "ページ"),
("View", "表示"),
("Help", "ヘルプ"),
("Preferences", "環境設定"),
("Preferences…", "環境設定…"),
("Interface language", "表示言語"),
("Open…", "開く…"),
("New blank PDF", "空白の PDF を作成"),
("Create PDF from file…", "ファイルから PDF を作成…"),
("Create PDF from images…", "画像から PDF を作成…"),
("Create PDF from clipboard", "クリップボードから PDF を作成"),
("Combine files…", "ファイルを結合…"),
("Save", "保存"),
("Save as…", "別名で保存…"),
("Close file", "ファイルを閉じる"),
("Close all", "すべて閉じる"),
("Revert", "保存済みの状態に戻す"),
("Print…", "印刷…"),
("Document properties…", "文書のプロパティ…"),
("Undo", "取り消し"),
("Redo", "やり直し"),
("Find…", "検索…"),
("Advanced search…", "高度な検索…"),
("Copy pages", "ページをコピー"),
("Cut pages", "ページを切り取り"),
("Paste pages", "ページを貼り付け"),
("Fit visible", "表示範囲に合わせる"),
("Marquee zoom", "範囲指定ズーム"),
("Take a snapshot", "スナップショットを作成"),
("Full screen mode", "全画面表示"),
("Read mode", "閲覧モード"),
("Switch light / dark theme", "明るい/暗いテーマを切り替え"),
("Comments panel", "コメントパネル"),
("Form fields panel", "フォームフィールドパネル"),
("Clear form", "フォームをクリア"),
("Find tools and commands…", "ツールとコマンドを検索…"),
("Zoom", "ズーム"),
("Actual size", "実際のサイズ"),
("Zoom to page level", "ページ全体を表示"),
("Fit to width", "幅に合わせる"),
("Display theme", "表示テーマ"),
("Side panels", "サイドパネル"),
("OK", "OK"),
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn translations_are_unique_and_preserve_unknown_text() {
for (i, (en, ja)) in JAPANESE.iter().enumerate() {
assert!(!ja.is_empty());
assert!(JAPANESE.iter().take(i).all(|(other, _)| en != other));
assert_eq!(Language::En.tr(en), *en);
}
assert_eq!(Language::Ja.tr("File"), "ファイル");
assert_eq!(Language::Ja.tr("日本語の文書.pdf"), "日本語の文書.pdf");
assert_eq!(Language::parse("xx"), None);
}
#[test]
fn language_command_validates_and_persists_without_a_document() {
let mut app = crate::VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
crate::menus::run_ui_command(&mut app, "app.language", &serde_json::json!({"lang": "ja"})).unwrap().unwrap();
assert_eq!(app.ui.language, Language::Ja);
assert_eq!(crate::menus::checked(&app, "app.language", &serde_json::json!({"lang": "ja"})), Some(true));
assert!(crate::menus::run_ui_command(&mut app, "app.language", &serde_json::json!({"lang": "xx"})).unwrap().is_err());
assert_eq!(app.ui.language, Language::Ja);
let saved = serde_json::to_string(&app.ui).unwrap();
let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap();
assert_eq!(restored.language, Language::Ja);
}
#[test]
fn japanese_glyphs_are_available_without_system_fonts() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
output.textures_delta.clear();
ctx.fonts_mut(|fonts| {
let families: Vec<_> = fonts.definitions().families.keys().cloned().collect();
for family in families {
let font = egui::FontId::new(13.0, family);
for ch in "日本語ファイル編集".chars() {
assert!(fonts.has_glyph(&font, ch), "missing {ch} in {font:?}");
}
}
});
}
}
+1
View File
@@ -15,6 +15,7 @@ pub mod cursors;
pub mod dialogs;
pub mod dock;
pub mod find_font;
pub mod i18n;
pub mod icon_data;
pub mod icons;
pub mod io;
+26 -6
View File
@@ -50,6 +50,7 @@ use Item::Sep;
/// UI-level commands: (id, label, shortcut, params doc).
pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("app.language", "Interface Language", "", "{lang: en|ja} — persistent interface language"),
("file.open", "Open…", "Cmd+O", "{path?}"),
(
"file.save",
@@ -671,6 +672,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
/// Handle a UI command. `None` = not a UI command (the engine handles it).
pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
if id == "app.language" {
let language = p.get("lang").and_then(Value::as_str).and_then(crate::i18n::Language::parse);
let Some(language) = language else { return Some(Err("lang must be en or ja".into())) };
app.ui.language = language;
return Some(Ok(json!(language)));
}
if let Some(r) = crate::panels::character::intercept_text_command(app, id) {
return Some(r);
}
@@ -1102,8 +1109,19 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
/// Checked state for toggle items.
pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
if id == "app.language" {
return Some(p.get("lang").and_then(Value::as_str).and_then(crate::i18n::Language::parse) == Some(app.ui.language));
}
let v = &app.ui.view;
Some(match id {
"type.orientation.vertical" | "type.orientation.horizontal" => {
let st = app.session.active()?;
let t = st.selection.objects.iter().find_map(|id| match st.doc.node(*id).map(|n| &n.kind) {
Some(vectorcraft_engine::doc::NodeKind::Text(t)) => Some(t),
_ => None,
})?;
t.vertical == (id == "type.orientation.vertical")
}
"view.outline" => v.outline,
"view.pixelPreview" => v.pixel_preview,
"view.trimView" => v.trim_view,
@@ -1432,6 +1450,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
c("Join Our Discord", "help.discord"),
Sep,
c("Settings…", "edit.preferences"),
sub("Language", vec![cp("English", "app.language", json!({"lang": "en"})), cp("日本語", "app.language", json!({"lang": "ja"}))]),
Sep,
sub("UI Brightness", Brightness::ALL.iter().map(|b| cp(b.label(), "window.brightness", json!({"brightness": b.id()}))).collect()),
Sep,
@@ -1837,7 +1856,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
c("Fill with Placeholder Text", "type.fillPlaceholder"),
Sep,
c("Show Hidden Characters", "type.hiddenCharacters"),
sub("Type Orientation", vec![todo("Horizontal"), todo("Vertical")]),
sub("Type Orientation", vec![c("Horizontal", "type.orientation.horizontal"), c("Vertical", "type.orientation.vertical")]),
],
),
(
@@ -2094,9 +2113,9 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
.ui(ui, |ui| {
for (i, (title, items)) in tree.iter().enumerate() {
let text = if i == 0 {
egui::RichText::new(*title).font(theme::semibold(13.0)).color(t.text)
egui::RichText::new(app.ui.language.tr(title)).font(theme::semibold(13.0)).color(t.text)
} else {
egui::RichText::new(*title).size(13.0).color(t.text)
egui::RichText::new(app.ui.language.tr(title)).size(13.0).color(t.text)
};
ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked));
}
@@ -2126,10 +2145,10 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked
ui.separator();
}
Item::Header(h) => {
ui.label(egui::RichText::new(*h).size(11.0).color(t.text_dim));
ui.label(egui::RichText::new(app.ui.language.tr(h)).size(11.0).color(t.text_dim));
}
Item::Sub(label, children) => {
ui.menu_button(*label, |ui| {
ui.menu_button(app.ui.language.tr(label), |ui| {
widgets::menu_scroll(ui, |ui| {
ui.set_min_width(200.0);
render_items(app, ui, children, clicked);
@@ -2138,7 +2157,7 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked
}
Item::Todo(label, sc) => {
ui.add_enabled_ui(false, |ui| {
ui.add(egui::Button::new(*label).shortcut_text(pretty_shortcut(sc)));
ui.add(egui::Button::new(app.ui.language.tr(label)).shortcut_text(pretty_shortcut(sc)));
})
.response
.on_disabled_hover_text("Coming soon — tracked in the parity plan");
@@ -2150,6 +2169,7 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked
continue;
}
let label = dynamic_label(app, id, label);
let label = app.ui.language.tr(&label).to_string();
let sc = item_shortcut(id, p).map(pretty_shortcut).unwrap_or_default();
let chk = checked(app, id, p);
let text = match chk {
+3
View File
@@ -207,6 +207,8 @@ impl Dialog {
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct UiState {
#[serde(default)]
pub language: crate::i18n::Language,
pub brightness: Brightness,
pub dock_tab: DockTab,
/// Icon panel currently popped out of the collapsed column.
@@ -342,6 +344,7 @@ impl UiState {
impl Default for UiState {
fn default() -> Self {
Self {
language: crate::i18n::Language::default(),
brightness: Brightness::MediumDark,
dock_tab: DockTab::Properties,
open_panel: None,
+4
View File
@@ -254,6 +254,10 @@ pub fn install_fonts(ctx: &egui::Context) {
let mut mono = vec!["JetBrainsMono".to_string()];
mono.extend(fallback);
fonts.families.insert(FontFamily::Name(FONT_MONO.into()), mono);
add(&mut fonts, "japanese", vectorcraft_text::SHIPPORI_MINCHO_REGULAR);
for stack in fonts.families.values_mut() {
stack.push("japanese".into());
}
ctx.set_fonts(fonts);
}