mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Merge pull request #145 from midasdf/feat/japanese-interface-language
Add Japanese menus and initial vertical type tools
This commit is contained in:
26 files changed
+522
-56
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.
|
||||
@@ -178,7 +178,7 @@ self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-0
|
||||
**What's missing:**
|
||||
- 3D and Materials;
|
||||
- the Photoshop-style raster effects (Effect Gallery);
|
||||
- vertical and CJK type;
|
||||
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
|
||||
- Variables and scripting;
|
||||
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
|
||||
- packaging for Windows and Linux.
|
||||
|
||||
+18
-16
@@ -14,8 +14,8 @@ _Last updated: 2026-10-06 (after M4.14–M4.98, M8.1–M8.20 and M14.4–M14.7;
|
||||
| Look & feel vs Illustrator 2026 default workspace (measured) | **~75–80%** |
|
||||
| Feature surface vs full Illustrator (weighted, see below) | **~75%** on the rubric · **69–75%** honest range (self-graded, see [Honest assessment](#honest-assessment-2026-10-05)) |
|
||||
| Parity including interaction fidelity and hardening ("a power user can't tell the difference, but faster") | **~40–55%** |
|
||||
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~225–345 h** one agent · **~60–100 h** with 4–6 agents |
|
||||
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~330–515 h** one agent · **~85–150 h** with 4–6 agents |
|
||||
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~210–330 h** one agent · **~55–95 h** with 4–6 agents |
|
||||
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~320–500 h** one agent · **~80–145 h** with 4–6 agents |
|
||||
|
||||
## Honest assessment (2026-10-05)
|
||||
|
||||
@@ -33,9 +33,9 @@ since 2026-10-01.
|
||||
|
||||
| Dimension | Estimate | Evidence and what's missing |
|
||||
|---|---|---|
|
||||
| **Breadth:** menus, tools and panels exist | ~90% | 18 menu items still stubbed; every tool implemented except Touch Type and vertical type ×3; 51 panel modules |
|
||||
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~35%), brushes and symbols (in progress), raster effects (~20%) |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, vertical/CJK type, Variables (data merge), scripting |
|
||||
| **Breadth:** menus, tools and panels exist | ~90% | 16 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
|
||||
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%), brushes and symbols (in progress), raster effects (~20%) |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type has initial support), Variables (data merge), scripting |
|
||||
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
|
||||
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
|
||||
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Native `.ai` private data is out of scope by design; DWG has no open spec |
|
||||
@@ -58,9 +58,10 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
|
||||
3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software
|
||||
renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors.
|
||||
The largest single gap. 50–80 h.
|
||||
4. **Advanced type:** vertical type and its tools, CJK composition, Optical Margin Alignment, the
|
||||
composer/hyphenation options, tab leaders, a spell-check dictionary (open licence), Touch Type and Snap to Glyph.
|
||||
41–55 h across type core and advanced.
|
||||
4. **Advanced type:** CJK composition for vertical type (kinsoku, ruby, tate-chu-yoko, vertical font metrics,
|
||||
mixed-script orientation), Optical Margin Alignment, the composer/hyphenation options, tab leaders, a
|
||||
spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have
|
||||
initial support. 35–48 h across type core and advanced.
|
||||
5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries
|
||||
generated in code (never Adobe's); dynamic symbols; Start Global Edit. 14–22 h.
|
||||
6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h.
|
||||
@@ -111,6 +112,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
|
||||
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline.
|
||||
- **Japanese interface and vertical type:** VectorCraft ▸ Language switches the menus between English and Japanese (untranslated labels stay English) and persists; the bundled OFL Shippori Mincho gives Japanese glyphs without system fonts. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs with `vert`/`vrt2` alternates); Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
|
||||
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
|
||||
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type, Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
- **Paint and appearance (M3):**
|
||||
@@ -153,7 +155,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; accessibility, Windows/Linux packaging pending | 18–28 |
|
||||
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
|
||||
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
|
||||
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~330–515** |
|
||||
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~320–500** |
|
||||
|
||||
## Parity estimate
|
||||
|
||||
@@ -179,7 +181,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 |
|
||||
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
|
||||
| Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters | 15–20 |
|
||||
| Type advanced | 4 | 35% | vertical type/CJK, tab leaders, spell check (open dictionary), Touch Type, Retype | 26–35 |
|
||||
| Type advanced | 4 | 45% | CJK composition for vertical type (kinsoku, ruby, tate-chu-yoko, vertical metrics, mixed-script orientation), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
|
||||
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 |
|
||||
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 |
|
||||
@@ -191,17 +193,17 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
|
||||
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows (Links, Asset Export, CSS Properties, Attributes are done) | 15–25 |
|
||||
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 |
|
||||
| **Feature parity** | **103** | **~75%** | | **~225–345** |
|
||||
| **Feature parity** | **103** | **~75%** | | **~210–330** |
|
||||
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
|
||||
| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 |
|
||||
| **Full parity** | | **~56%** | | **~330–515** |
|
||||
| **Full parity** | | **~56%** | | **~320–500** |
|
||||
|
||||
With 4–6 agents working on disjoint crates (as the layering allows) the wall-clock time divides by roughly 3.5–4
|
||||
(integration, review and shared files such as `menus.rs` serialize some work): **~60–100 h** to feature parity,
|
||||
**~85–150 h** to full parity.
|
||||
(integration, review and shared files such as `menus.rs` serialize some work): **~55–95 h** to feature parity,
|
||||
**~80–145 h** to full parity.
|
||||
|
||||
_Inventories (2026-10-05):_ 18 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
|
||||
implemented except four (Touch Type, vertical type ×3); 51 panel modules; Illustrator-style live
|
||||
_Inventories (2026-10-05):_ 16 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
|
||||
implemented except Touch Type; 51 panel modules; Illustrator-style live
|
||||
effects ~44/54, Photoshop-style raster effects ~1/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust.
|
||||
|
||||
_Where we already beat Illustrator:_ exact curve booleans, off-thread multithreaded rendering, undo that never runs out,
|
||||
|
||||
@@ -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,149 @@
|
||||
//! Interface translations. Command ids, document text and file names remain stable.
|
||||
//! Untranslated labels fall back to English so coverage can grow incrementally. Every entry is a
|
||||
//! label VectorCraft's menus show (tested), so labels from other apps don't creep in.
|
||||
|
||||
#[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…", "環境設定…"),
|
||||
("Type", "書式"),
|
||||
("Select", "選択"),
|
||||
("Window", "ウィンドウ"),
|
||||
("Language", "表示言語"),
|
||||
("Save As…", "別名で保存…"),
|
||||
("New…", "新規…"),
|
||||
("Horizontal", "横書き"),
|
||||
("Vertical", "縦書き"),
|
||||
("Type Orientation", "組み方向"),
|
||||
("Layers", "レイヤー"),
|
||||
("History", "履歴"),
|
||||
("Properties", "プロパティ"),
|
||||
("Color", "カラー"),
|
||||
("Tools", "ツール"),
|
||||
("Zoom In", "ズームイン"),
|
||||
("Zoom Out", "ズームアウト"),
|
||||
("Copy", "コピー"),
|
||||
("Cut", "切り取り"),
|
||||
("Paste", "貼り付け"),
|
||||
("Deselect", "選択を解除"),
|
||||
("Export", "書き出し"),
|
||||
("Export As…", "形式を指定して書き出し…"),
|
||||
("File", "ファイル"),
|
||||
("Edit", "編集"),
|
||||
("View", "表示"),
|
||||
("Help", "ヘルプ"),
|
||||
("Preferences…", "環境設定…"),
|
||||
("Open…", "開く…"),
|
||||
("Save", "保存"),
|
||||
("Revert", "保存済みの状態に戻す"),
|
||||
("Print…", "印刷…"),
|
||||
("Undo", "取り消し"),
|
||||
("Redo", "やり直し"),
|
||||
];
|
||||
|
||||
#[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 every_translated_label_is_a_menu_label() {
|
||||
fn walk(items: &[crate::menus::Item], out: &mut Vec<&'static str>) {
|
||||
for i in items {
|
||||
match i {
|
||||
crate::menus::Item::Cmd(l, ..) | crate::menus::Item::Todo(l, _) | crate::menus::Item::Header(l) => out.push(l),
|
||||
crate::menus::Item::Sub(l, children) => {
|
||||
out.push(l);
|
||||
walk(children, out);
|
||||
}
|
||||
crate::menus::Item::Sep => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut labels = vec![];
|
||||
for (title, items) in crate::menus::menu_tree() {
|
||||
labels.push(title);
|
||||
walk(&items, &mut labels);
|
||||
}
|
||||
for (en, _) in JAPANESE {
|
||||
assert!(labels.contains(en), "`{en}` isn't a VectorCraft menu label");
|
||||
}
|
||||
}
|
||||
|
||||
#[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