mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
i18n: add Japanese menus and initial vertical type tools
This commit is contained in:
1 parent
2e2bb33909
commit
44b2b984ed
25 files changed
+537
-39
No files matched your search
@@ -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`) | |
|
||||
|
||||
@@ -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
@@ -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,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)
|
||||
};
|
||||
|
||||
@@ -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.
@@ -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 }],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ¶s, &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, ¶s, &t.para, Some(®ions));
|
||||
}
|
||||
TextKind::OnPath { path, start } => on_path(&mut cx, ¶s, &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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user