From 5623f90ef761e0e73857e1c01aa9d4cbb065d8e1 Mon Sep 17 00:00:00 2001 From: imachus <51533227+imachus@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:20:18 +0900 Subject: [PATCH] Type: justified Japanese lines spread between their characters Justify (last line left, centred or right) widened only word spaces, so a Japanese line, which has none, stayed set solid and the right edge of a justified Japanese paragraph was ragged by up to almost a character (only Justify All spread the characters). Following JLREQ 3.8, the room left on a line now goes to the gaps next to a CJK character, shared with any word spaces: - not inside a Latin word or a tate-chu-yoko block, nor next to a tab; - lines without CJK characters are justified as before; - vertical type too (the same code, in line space). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/text/src/layout.rs | 24 +++++++++++++++++++++--- crates/text/src/shape.rs | 2 +- crates/text/src/tests.rs | 31 +++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d329c038..613a9140 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -120,7 +120,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; 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; 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); 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, 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):** diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 1c491704..c0fcbc39 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::{CharStyle, Justify, ParaStyle, PathEffect, TextKind, TextO use crate::composer::{Breakpoint, compose}; use crate::fontdb::FontDb; use crate::hyphen::hyphen_points; -use crate::shape::{SGlyph, Tcy, cap_x_heights, hyphen_glyph, no_line_end, no_line_start, shape_range, style_metrics}; +use crate::shape::{SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, no_line_end, no_line_start, shape_range, style_metrics}; use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, OtFeatures, PositionedGlyph, TextLayout}; const EPS: f64 = 1e-6; @@ -828,10 +828,24 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Opt Justify::JustifyAll => (0, true), }; let justify = justify && regions.is_some(); - let (mut per_space, mut per_gap) = (0.0, 0.0); + let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0); let spaces = sg[i..trimmed].iter().filter(|g| g.is_space()).count(); + // Japanese (and Chinese) lines are justified between their characters (JLREQ 3.8): the + // gaps next to a CJK character, not inside a Latin word or a tate-chu-yoko block. + let cjk_gap = |j: usize| { + j + 1 < trimmed + && sg + .get(j) + .zip(sg.get(j + 1)) + .is_some_and(|(a, b)| !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch))) + }; + let cjk_gaps = (i..trimmed).filter(|&j| cjk_gap(j)).count(); if justify && (width - w).abs() > EPS { - if spaces > 0 { + if cjk_gaps > 0 && width > w { + // Spread over the CJK gaps and the word spaces alike. + let per = (width - w) / (cjk_gaps + spaces) as f64; + (per_space, per_cjk) = (per, per); + } else if spaces > 0 { // Composed lines may shrink word spaces (never below zero). per_space = ((width - w) / spaces as f64).max(-sg[i..trimmed].iter().filter(|g| g.is_space()).map(|g| g.adv).fold(f64::MAX, f64::min)); @@ -870,6 +884,10 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Opt } else if j < trimmed { if g.is_space() { adv += per_space; + } else if per_cjk != 0.0 { + if cjk_gap(j) { + adv += per_cjk; + } } else if j + 1 < trimmed && sg.get(j + 1).is_some_and(|next| !next.continues_tcy()) { // Between glyphs, never inside a tate-chu-yoko block (one cell). adv += per_gap; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index b0d77643..4e9f4c1f 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -137,7 +137,7 @@ pub(crate) fn no_line_end(c: char) -> bool { ) } -fn is_cjk(c: char) -> bool { +pub(crate) fn is_cjk(c: char) -> bool { matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF) } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index dde4c2a9..8837ddab 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -491,3 +491,34 @@ fn vertical_area_type_wraps_into_columns_inside_the_frame() { 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); } + +/// Justified Japanese lines (no word spaces) spread the leftover room over the gaps between their +/// characters (JLREQ 3.8), not inside a Latin word; the last line of a paragraph stays set solid. +#[test] +fn justified_japanese_lines_spread_between_characters() { + // The ideographs' advance in the fonts at hand, measured; five fit a line, 10 pt left over. + let measure = layout(db(), &point("一", style(20.0))); + let a = measure.glyphs[0].advance; + let width = 5.0 * a + 10.0; + let t = area("一二三四五六七", style(20.0), Rect::new(0.0, 0.0, width, 400.0), Justify::JustifyLeft); + let l = layout(db(), &t); + let first = &l.glyphs[l.lines[0].glyph_start..l.lines[0].glyph_end]; + assert_eq!(first.len(), 5); + for w in first.windows(2) { + assert!((w[1].origin.x - w[0].origin.x - (a + 2.5)).abs() < 0.01, "10 pt over 4 gaps"); + } + let end = first.last().map(|g| g.origin.x + a).unwrap(); + assert!((end - width).abs() < 0.01, "the line reaches the frame's edge: {end} vs {width}"); + // The last line keeps its natural spacing. + let last = &l.glyphs[l.lines[1].glyph_start..l.lines[1].glyph_end]; + assert!((last[1].origin.x - last[0].origin.x - a).abs() < 0.01); + // A Latin word inside a Japanese line keeps its letters together; the gaps around it share. + let t = area("一二ABC三四五六七八九十一二三", style(20.0), Rect::new(0.0, 0.0, 8.0 * a + 10.0, 400.0), Justify::JustifyLeft); + let l = layout(db(), &t); + let g = &l.glyphs[l.lines[0].glyph_start..l.lines[0].glyph_end]; + let at = g.iter().position(|g| g.byte == "一二".len()).unwrap(); + let solid = + |k: usize| (g[k + 1].origin.x - g[k].origin.x - layout(db(), &point(&"ABC"[k - at..=k - at], style(20.0))).glyphs[0].advance).abs() < 0.01; + assert!(solid(at) && solid(at + 1), "A–B–C set solid"); + assert!(g[at].origin.x - g[at - 1].origin.x > a + 0.1, "二–A takes its share"); +}