diff --git a/ROADMAP.md b/ROADMAP.md index a7148f21..a20c464f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -125,7 +125,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. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. - 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; several loose objects are grouped first, in one undo step), 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. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. -- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional Chinese covers the whole interface, Czech and Japanese every menu label (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech ones from the bundled UI fonts. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese, and justified Japanese lines spread their room between characters (not inside Latin words); 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. +- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional Chinese covers the whole interface, Czech and Japanese every menu label (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech ones from the bundled UI fonts. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese, and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph › Mojikumi: Line-end Punctuation Half Width (new type's default) shares one half-em space between consecutive punctuation and sets a closing mark ending a line half width; 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 `` 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 (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder, Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). - **Paint and appearance (M3):** @@ -196,7 +196,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, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 15–20 | -| Type advanced | 4 | 49% | CJK composition for vertical type (ruby, mojikumi, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 | +| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — Japanese–Latin spacing, hanging punctuation, kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), 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 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard presets/rearrange polish (Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ab838557..08057b61 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -77,8 +77,8 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource}; pub use style_libs::StyleLibrary; pub use text::{ - AreaOptions, CharPosition, CharStyle, FirstBaseline, Justify, ParaStyle, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, - TextRun, TextStyleDef, TextWrap, WrapShape, + AreaOptions, CharPosition, CharStyle, FirstBaseline, Justify, Mojikumi, ParaStyle, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, + TextObject, TextRun, TextStyleDef, TextWrap, WrapShape, }; pub use vectorcraft_color as color; pub use vectorcraft_geom as geom; diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 8c90092a..e06124b1 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -287,6 +287,31 @@ pub struct ParaStyle { /// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal. #[serde(default, skip_serializing_if = "Option::is_none")] pub style_name: Option, + /// Japanese composition: the spacing of punctuation (Paragraph panel › Mojikumi). Type made + /// with the Type tools and `text.create` takes [`Mojikumi::LineEndHalf`]; documents from before + /// it and imported text (already set) keep [`Mojikumi::None`]. + #[serde(default, skip_serializing_if = "Mojikumi::is_none")] + pub mojikumi: Mojikumi, +} + +/// How Japanese punctuation is spaced (JLREQ 3.1). Full-width punctuation is half a glyph and half +/// a space: an opening bracket's space before it, a closing bracket's, a comma's or a full stop's +/// after it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Mojikumi { + /// Every character takes its full advance. + #[default] + None, + /// Consecutive punctuation shares one half-em space (JLREQ 3.1.4), and a closing bracket, + /// comma or full stop ending a line is set half width. + LineEndHalf, +} + +impl Mojikumi { + pub fn is_none(&self) -> bool { + *self == Mojikumi::None + } } /// Area Type Options "First Baseline" offset. diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 480cf9c1..28501c4c 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -402,6 +402,8 @@ fn text_create(s: &mut Session, p: &Value) -> Result { 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); + // New type is composed the Japanese way (punctuation spacing), as the Type tools set it. + t.para.mojikumi = vectorcraft_doc::Mojikumi::LineEndHalf; if let Some(a) = p.get("area") { let w = f64_or(a, "width", 200.0); let h = f64_or(a, "height", 100.0); diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index f896ef68..73aacfd3 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool}", + "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing)}", has_doc, set_format ), @@ -111,11 +111,20 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "spaceBefore", "spaceAfter", "hyphenate", + "mojikumi", ]; if !keys.iter().any(|k| p.get(*k).is_some()) { return Err(bad(C, "nothing to change")); } let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?; + let mojikumi = match p.get("mojikumi") { + None => None, + Some(v) => Some(match v.as_str() { + Some("none") => vectorcraft_doc::Mojikumi::None, + Some("lineEndHalf") => vectorcraft_doc::Mojikumi::LineEndHalf, + _ => return Err(bad(C, "`mojikumi` must be \"none\" or \"lineEndHalf\"")), + }), + }; s.edit("Character", |d, _| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; @@ -171,6 +180,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = flag("hyphenate") { para.hyphenate = v; } + if let Some(v) = mojikumi { + para.mojikumi = v; + } super::typecmd::refresh_bounds(t); } Ok(()) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 68195b5b..e341e6ee 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -378,6 +378,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { wrap: Vec::new(), cached_bounds: None, }; + t.para.mojikumi = vectorcraft_doc::Mojikumi::LineEndHalf; refresh_bounds(&mut t); let id = s.edit(if on_path { "Type on a Path" } else { "Area Type" }, |d, sel| { let (par, idx, _) = d.position(pid).ok_or(EngineError::NoNode(pid))?; diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index f8d044e5..ed0b7880 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -407,6 +407,31 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result { mod area_tests { use super::*; + #[test] + fn new_type_is_composed_with_line_end_half_width_punctuation() { + use vectorcraft_doc::Mojikumi; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "雅楽。"})).unwrap()["id"].as_u64().unwrap(); + let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.para.clone(), + _ => panic!("text"), + }; + assert_eq!(para(&s).mojikumi, Mojikumi::LineEndHalf); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + assert!(s.execute("text.setFormat", &json!({"mojikumi": "everything"})).is_err()); + s.execute("text.setFormat", &json!({"mojikumi": "none"})).unwrap(); + assert_eq!(para(&s).mojikumi, Mojikumi::None); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(para(&s).mojikumi, Mojikumi::LineEndHalf); + // Saved only when set; documents from before it read as None. + let json = serde_json::to_value(para(&s)).unwrap(); + assert_eq!(json["mojikumi"], "lineEndHalf"); + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); + assert_eq!(old.mojikumi, Mojikumi::None); + assert!(serde_json::to_value(&old).unwrap().get("mojikumi").is_none()); + } + #[test] fn vertical_point_type_keeps_its_anchor_on_the_column_centre_line() { let mut s = Session::new(); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 000cbc0d..4216b545 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -3,12 +3,12 @@ use std::ops::Range; use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Point, Rect, Shape, Vec2}; -use vectorcraft_doc::{CharStyle, Justify, ParaStyle, PathEffect, TextKind, TextObject}; +use vectorcraft_doc::{CharStyle, Justify, Mojikumi, ParaStyle, PathEffect, TextKind, TextObject}; use crate::composer::{Breakpoint, compose}; use crate::fontdb::FontDb; use crate::hyphen::hyphen_points; -use crate::shape::{SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, no_line_end, no_line_start, shape_range, style_metrics}; +use crate::shape::{Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, no_line_end, no_line_start, punct, shape_range, style_metrics}; use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, OtFeatures, PositionedGlyph, TextLayout}; const EPS: f64 = 1e-6; @@ -52,7 +52,12 @@ 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); + // A glyph whose leading space was taken off (mojikumi) is drawn that much earlier: an + // upright one in vertical type by turning about a point that much higher. + let upright = self.vertical && !self.on_path && g.tcy.is_none() && stands_upright(g); + let lead = if upright { 0.0 } else { g.lead }; + let local = + Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx - lead, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy); let mut m = pre * local; if self.vertical && self.on_path { // Vertical path type keeps the baseline path and turns each glyph across it. @@ -71,7 +76,7 @@ impl Ctx<'_> { // after the cell, and must not push the glyph off the centre line). let em = self.style_at(g.byte).size; let cell = if g.adv > 0.0 { upright_cell(g) } else { advance }; - m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - upright_centre(g, cell, em))) * m; + m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5 - g.lead, origin.y - upright_centre(g, cell, em))) * m; } // 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() { @@ -219,6 +224,43 @@ fn tate_chu_yoko(g: &mut [SGlyph], size: impl Fn(&SGlyph) -> f64) { } } +/// Half an em of `g`'s size when it is full-width Japanese punctuation of kind `kind` (its +/// advance an em, give or take a tenth), the space that mojikumi can take off. +fn punct_half(g: &SGlyph, kind: Punct) -> Option { + if punct(g.ch)? != kind || g.tcy.is_some() { + return None; + } + let em = g.face.units_per_em(); + ((g.face.advance(g.gid) - em).abs() <= em * 0.1).then_some(em * 0.5 * g.sx) +} + +/// Mojikumi (JLREQ 3.1.4): consecutive punctuation shares one half-em space. A closing bracket, +/// comma or full stop followed by punctuation loses the space after it; an opening bracket after +/// another loses the space before it. Returns which glyphs lost the space after them (a line +/// ending in one takes nothing more off). +fn compress_punctuation(sg: &mut [SGlyph]) -> Vec { + let mut lost_after = vec![false; sg.len()]; + for j in 1..sg.len() { + let (before, after) = sg.split_at_mut(j); + let (Some(a), Some(b)) = (before.last_mut(), after.first_mut()) else { continue }; + if punct(b.ch).is_none() { + continue; + } + if let Some(h) = punct_half(a, Punct::Closing) { + a.adv -= h; + if let Some(l) = lost_after.get_mut(j - 1) { + *l = true; + } + } else if punct(a.ch) == Some(Punct::Opening) + && let Some(h) = punct_half(b, Punct::Opening) + { + b.adv -= h; + b.lead += h; + } + } + lost_after +} + /// The length an upright glyph takes down the column before tracking and justification: its /// vertical advance (the font's vertical metrics), else its advance, at least one em. fn upright_cell(g: &SGlyph) -> f64 { @@ -777,7 +819,9 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>) -> fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Option<&[Region]>) { let mut pen = Pen { regions, ri: 0, prev: None, pending: 0.0, fb: cx.opts.first_baseline, fb_min: cx.opts.first_baseline_min, queued: vec![] }; 'paras: for (pi, pr) in paras.iter().enumerate() { - let sg = cx.shape_para(pr.clone()); + let mut sg = cx.shape_para(pr.clone()); + // Japanese composition: consecutive punctuation shares one half-em space. + let compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] }; let pm = { let (asc, desc, lead) = style_metrics(cx.db, cx.style_at(pr.start)); let (cap, xh) = cap_x_heights(cx.db, cx.style_at(pr.start)); @@ -835,7 +879,12 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Opt trimmed -= 1; } let hyphen = (hyph && end > i).then(|| hyphen_glyph(&sg[end - 1])); - let w: f64 = sg[i..trimmed].iter().map(|g| g.adv).sum::() + hyphen.as_ref().map_or(0.0, |h| h.adv); + // Mojikumi: a closing bracket, comma or full stop ending the line is set half width. + let end_trim = match trimmed.checked_sub(1).filter(|&k| k >= i && para.mojikumi == Mojikumi::LineEndHalf) { + Some(k) if !compressed.get(k).copied().unwrap_or(false) => sg.get(k).and_then(|g| punct_half(g, Punct::Closing)).unwrap_or(0.0), + _ => 0.0, + }; + let w: f64 = sg[i..trimmed].iter().map(|g| g.adv).sum::() + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim; let (align, justify) = match para.justify { Justify::Left => (0, false), Justify::Center => (1, false), @@ -897,6 +946,9 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Opt let tab_origin = if regions.is_some() { x0 } else { 0.0 }; for (j, g) in sg.iter().enumerate().take(end).skip(i) { let mut adv = g.adv; + if j + 1 == trimmed { + adv -= end_trim; + } if g.ch == '\t' { adv = tab_advance(¶.tabs, tab_origin, x, &sg[j + 1..trimmed.max(j + 1)]); } else if j < trimmed { diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 4e9f4c1f..265c74d8 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -42,6 +42,9 @@ pub(crate) struct SGlyph { pub ch: char, /// Vertical type: part of a tate-chu-yoko block (set across the column, upright). pub tcy: Option, + /// Japanese composition: the space taken off before the glyph (an opening bracket after + /// another, see [`crate::layout`]); the glyph is drawn that much earlier on the line. + pub lead: f64, } /// A glyph's place in a tate-chu-yoko block: the block takes one em of the column, its glyphs side @@ -137,6 +140,25 @@ pub(crate) fn no_line_end(c: char) -> bool { ) } +/// Full-width punctuation for Japanese composition (JLREQ cl-01, cl-02, cl-06, cl-07). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Punct { + /// An opening bracket: its half-em space is before the mark. + Opening, + /// A closing bracket, comma or full stop: its half-em space is after the mark. + Closing, +} + +pub(crate) fn punct(c: char) -> Option { + match c { + '(' | '「' | '『' | '【' | '〔' | '〈' | '《' | '[' | '{' | '〘' | '〖' | '⦅' | '〝' => Some(Punct::Opening), + ')' | '」' | '』' | '】' | '〕' | '〉' | '》' | ']' | '}' | '〙' | '〗' | '⦆' | '〟' | '、' | '。' | ',' | '.' => { + Some(Punct::Closing) + } + _ => None, + } +} + pub(crate) fn is_cjk(c: char) -> bool { matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF) } @@ -316,6 +338,7 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec 2.0 { + return; // no font with full-width punctuation here + } + for vertical_type in [false, true] { + let adv = |text: &str, m| lay(text, m, vertical_type).glyphs.iter().map(|g| g.advance).collect::>(); + // Line end: 。 half width. + assert!((adv("一。", Mojikumi::LineEndHalf)[1] - 10.0).abs() < 0.01, "vertical {vertical_type}"); + assert!((adv("一。", Mojikumi::None)[1] - 20.0).abs() < 0.01); + // 」 before 「: the closing mark's space goes; 「 keeps its own. + let a = adv("一」「二", Mojikumi::LineEndHalf); + assert!((a[1] - 10.0).abs() < 0.01 && (a[2] - 20.0).abs() < 0.01, "{a:?}"); + // 「「: the second bracket loses the space before it and is drawn half an em earlier. + let a = adv("「「一", Mojikumi::LineEndHalf); + assert!((a[0] - 20.0).abs() < 0.01 && (a[1] - 10.0).abs() < 0.01, "{a:?}"); + let (on, off) = (lay("「「一", Mojikumi::LineEndHalf, vertical_type), lay("「「一", Mojikumi::None, vertical_type)); + let along = |r: Rect| if vertical_type { r.y0 } else { r.x0 }; + let moved = along(off.glyphs[1].outline.bounding_box()) - along(on.glyphs[1].outline.bounding_box()); + assert!((moved - 10.0).abs() < 0.01, "vertical {vertical_type}: the second 「 is drawn half an em earlier ({moved})"); + // Text without punctuation is untouched. + assert_eq!(adv("一二", Mojikumi::LineEndHalf), adv("一二", Mojikumi::None)); + } +} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c3d3f913..fe927fae 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -11,6 +11,8 @@ # grid preset names. Set `complete_menus` in i18n/mod.rs once every `tl!` literal has a row too. About VectorCraft VectorCraft について Join Our Discord Discord に参加 + Line-end Punctuation Half Width 行末約物半角 + Mojikumi 文字組み Settings… 環境設定… Language 表示言語 UI Brightness UI の明るさ diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b61e38d4..7318a836 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1273,6 +1273,7 @@ Line Segment 線段 Line Segment Tool 線段工具 Line Segment Tool Options 線段工具選項 + Line-end Punctuation Half Width 行尾標點半形 Linear Gradient 線性漸層 Lines 直線 Lines: clicks add points joined by a line 線條:每次點擊會新增以線連接的點 @@ -1412,6 +1413,7 @@ Modified since it was read: {path} 讀取後已被修改:{path} Modified: 修改時間: Modify 修改 + Mojikumi 標點間距 Monitor RGB 顯示器 RGB Mono 單色 Monochrome images 單色影像 diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 2257e43e..d919222e 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -85,6 +85,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if !pstate::(ui.ctx(), "pa-hide-options") && widgets::check(ui, tl!("Hyphenate"), para.hyphenate, true) { format(app, json!({"hyphenate": !para.hyphenate})); } + // Japanese composition: how punctuation is spaced (JLREQ 3.1). + ui.horizontal(|ui| { + widgets::dim_label(ui, tl!("Mojikumi")); + let sets = [tl!("None"), tl!("Line-end Punctuation Half Width")]; + let current = if para.mojikumi == vectorcraft_doc::Mojikumi::LineEndHalf { sets[1] } else { sets[0] }; + // Already translated: shown as they are. + if let Some(i) = widgets::dropdown_names(ui, "pa-mojikumi", current, &sets, ui.available_width() - 4.0) { + format(app, json!({"mojikumi": if i == 1 { "lineEndHalf" } else { "none" }})); + } + }); } pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {