diff --git a/ROADMAP.md b/ROADMAP.md index d329c038..c9e319e1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -35,7 +35,7 @@ since 2026-10-01. |---|---|---| | **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 with kinsoku and tate-chu-yoko; ruby, mojikumi and vertical metrics open), Variables (data merge), scripting | +| **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 with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), 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,7 +58,7 @@ 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:** CJK composition for vertical type (ruby, mojikumi, vertical font metrics, manual +4. **Advanced type:** CJK composition for vertical type (ruby, mojikumi, proportional vertical metrics, manual tate-chu-yoko), 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. @@ -191,7 +191,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 | 48% | CJK composition for vertical type (ruby, mojikumi, 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 | 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 | | 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 | diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 1046dec6..f81ec01f 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -142,6 +142,35 @@ impl FontFace { .map(|a| a as f64) .unwrap_or(self.upem * 0.5) } + /// Glyph `gid` set upright in vertical type, from the font's vertical metrics: (its advance down + /// the column, the height of its vertical origin, the top of its cell, above the baseline), in + /// font units. The origin is the `VORG` table's, else the glyph's top plus its top side bearing. + /// `None` when the font has no vertical metrics (`vhea`/`vmtx`), or they make no sense. + pub fn vertical_glyph(&self, gid: u32) -> Option<(f64, f64)> { + use skrifa::raw::TableProvider; + let f = self.skrifa()?; + let vmtx = f.vmtx().ok()?; + let g = GlyphId::new(gid); + let advance = f64::from(vmtx.advance(g)?); + let origin = match f.vorg() { + Ok(vorg) => f64::from(vorg.vertical_origin_y(g)), + Err(_) => { + let top = f.glyph_metrics(Size::unscaled(), self.location()).bounds(g).map_or(self.ascent, |b| f64::from(b.y_max)); + top + f64::from(vmtx.side_bearing(g)?) + } + }; + let em = self.upem; + // A glyph's cell is somewhere between a tenth of an em and a few ems, its top within a few + // ems of the baseline: anything else is a damaged table. + (advance > em * 0.1 && advance < em * 4.0 && origin.abs() < em * 4.0).then_some((advance, origin)) + } + /// Height above the baseline of the centre of the face's ideographic em box, in ems: from its + /// vertical metrics (an ideograph's cell), else the usual 0.38 (the box running from 0.12 em + /// below the baseline to 0.88 em above it). + pub fn ideographic_centre(&self) -> f64 { + let gid = ['国', 'あ', '一'].into_iter().map(|c| self.glyph_for(c)).find(|g| *g != 0); + gid.and_then(|g| self.vertical_glyph(g)).map_or(0.38, |(advance, origin)| (origin - advance * 0.5) / self.upem) + } /// Glyph id for `c` (0 = .notdef). pub fn glyph_for(&self, c: char) -> u32 { self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()).unwrap_or(0) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 1c491704..ebf7750a 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -38,8 +38,9 @@ impl Ctx<'_> { shape_range(self.db, self.text, r, &self.runs, &self.opts.features, &mut v); if self.vertical { tate_chu_yoko(&mut v, |g| self.style_at(g.byte).size); - // An upright glyph advances at least one em down the column (the vertical advance of - // CJK fonts), centred in it: a narrow mark like § must not overlap its neighbours. + // An upright glyph advances down the column by its vertical advance (the font's vertical + // metrics; without them at least one em, as CJK fonts' is), centred across it: a narrow + // mark like § must not overlap its neighbours. for g in v.iter_mut().filter(|g| g.adv > 0.0 && stands_upright(g)) { let extra = upright_cell(g) - g.face.advance(g.gid) * g.sx; g.dx += extra * 0.5; @@ -60,17 +61,17 @@ impl Ctx<'_> { // Tate-chu-yoko: the block set across the column, centred on its em of it. let em = self.style_at(g.byte).size; let start = origin.x - t.pen; - let centre = Point::new(start + em * 0.5, origin.y - EM_CENTER * em); + let centre = Point::new(start + em * 0.5, origin.y - g.face.ideographic_centre() * em); let across = Affine::translate((centre.x - t.width * 0.5 + t.ink - origin.x, 0.0)); let squeeze = Affine::translate((centre.x, 0.0)) * Affine::scale_non_uniform(t.squeeze, 1.0) * Affine::translate((-centre.x, 0.0)); m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, centre) * squeeze * across * m; } else if self.vertical && stands_upright(g) { - // Turned about the centre of its em box, which the column's centre line runs through: - // the middle of its own cell, not of its advance (tracking and justification add space + // Turned about the centre of its cell, which the column's centre line runs through: the + // middle of its own cell, not of its advance (tracking and justification add space // 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 - EM_CENTER * em)) * m; + 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; } // 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() { @@ -149,7 +150,11 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay let line_xf = match &t.kind { _ if !vertical => Affine::IDENTITY, TextKind::OnPath { .. } => Affine::IDENTITY, - TextKind::Point => Affine::translate((-EM_CENTER * cx.style_at(0).size, 0.0)) * QUARTER_TURN, + TextKind::Point => { + let first = cx.style_at(0); + let centre = db.face(&first.font_family, &first.font_style).map_or(EM_CENTER, |f| f.ideographic_centre()); + Affine::translate((-centre * first.size, 0.0)) * QUARTER_TURN + } _ => QUARTER_TURN, }; match &t.kind { @@ -215,13 +220,26 @@ fn tate_chu_yoko(g: &mut [SGlyph], size: impl Fn(&SGlyph) -> f64) { } /// The length an upright glyph takes down the column before tracking and justification: its -/// advance, at least one em. +/// vertical advance (the font's vertical metrics), else its advance, at least one em. fn upright_cell(g: &SGlyph) -> f64 { - (g.face.advance(g.gid) * g.sx).max(g.face.units_per_em() * g.sx) + match g.face.vertical_glyph(g.gid) { + Some((advance, _)) => advance * g.sy, + None => (g.face.advance(g.gid) * g.sx).max(g.face.units_per_em() * g.sx), + } +} + +/// Height above the baseline (line space) of the centre of an upright glyph's `cell`, the point it +/// turns about: from the font's vertical metrics, the cell hanging from the glyph's vertical origin; +/// else the centre of the ideographic em box ([`EM_CENTER`] of the size `em`). +fn upright_centre(g: &SGlyph, cell: f64, em: f64) -> f64 { + match g.face.vertical_glyph(g.gid) { + Some((_, origin)) => origin * g.sy - cell * 0.5, + None => EM_CENTER * em, + } } /// Height of the centre of the ideographic em box above the baseline, in ems (the em box runs -/// from 0.12 em below the baseline to 0.88 em above it). +/// from 0.12 em below the baseline to 0.88 em above it), for fonts without vertical metrics. const EM_CENTER: f64 = 0.38; /// A quarter turn clockwise (y down), exact: line space → text space for vertical type. diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index e8dc8222..fd687522 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -110,3 +110,67 @@ pub fn name_table(records: &[(u16, &str)]) -> Option> { head.extend(strings); Some(head) } + +/// The family of [`vertical_font`]. +pub const VERTICAL_FAMILY: &str = "Vertitest Sans"; + +/// The character whose vertical metrics [`vertical_font`] sets apart: 1.4 em down the column, its +/// vertical origin 1.1 em above the baseline. +pub const VERTICAL_TALL: char = '§'; + +/// The bundled Source Sans 3 Regular as family [`VERTICAL_FAMILY`] with vertical metrics (`vhea`, +/// `vmtx`, and `VORG` when `vorg`): every glyph one em down the column with its vertical origin at +/// the usual 0.88 em, except [`VERTICAL_TALL`] (1.4 em, origin 1.1 em). Without `VORG` the origin +/// comes from each glyph's top and top side bearing, as in TrueType fonts. `None` when it can't be +/// built. +pub fn vertical_font(vorg: bool) -> Option> { + const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + let font = skrifa::FontRef::new(BASE).ok()?; + let n = font.maxp().ok()?.num_glyphs(); + let metrics = font.glyph_metrics(Size::unscaled(), LocationRef::default()); + let tall = font.charmap().map(VERTICAL_TALL)?; + let cell = |g: GlyphId| -> (u16, i16) { if g == tall { (1400, 1100) } else { (1000, 880) } }; + let be16 = |v: &mut Vec, x: i16| v.extend_from_slice(&x.to_be_bytes()); + // vmtx: (advance height, top side bearing = origin − the glyph's top) for every glyph. + let mut vmtx = Vec::with_capacity(4 * usize::from(n)); + for g in 0..u32::from(n) { + let id = GlyphId::new(g); + let (advance, origin) = cell(id); + let top = metrics.bounds(id).map_or(0.0, |b| b.y_max).round() as i16; + vmtx.extend_from_slice(&advance.to_be_bytes()); + be16(&mut vmtx, origin - top); + } + // vhea 1.1: ascent/descent of the em box across the column, then the metrics' count. + let mut vhea = Vec::with_capacity(36); + vhea.extend_from_slice(&0x0001_1000_u32.to_be_bytes()); + for x in [500, -500, 0] { + be16(&mut vhea, x); + } + vhea.extend_from_slice(&1400_u16.to_be_bytes()); + for x in [0, 0, 1400, 1, 0, 0, 0, 0, 0, 0, 0] { + be16(&mut vhea, x); + } + vhea.extend_from_slice(&n.to_be_bytes()); + let name = name_table(&[(1, VERTICAL_FAMILY), (2, "Regular"), (4, "Vertitest Sans Regular"), (6, "VertitestSans-Regular")])?; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + builder.add_raw(Tag::new(b"vhea"), vhea); + builder.add_raw(Tag::new(b"vmtx"), vmtx); + if vorg { + // VORG 1.0: the default origin, then the one glyph that differs. + let mut t = Vec::new(); + for x in [1, 0, 880, 1] { + be16(&mut t, x); + } + t.extend_from_slice(&u16::try_from(tall.to_u32()).ok()?.to_be_bytes()); + be16(&mut t, 1100); + builder.add_raw(Tag::new(b"VORG"), t); + } + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} diff --git a/crates/text/src/tests_vertical.rs b/crates/text/src/tests_vertical.rs index 35b9b012..4c56eee1 100644 --- a/crates/text/src/tests_vertical.rs +++ b/crates/text/src/tests_vertical.rs @@ -261,3 +261,33 @@ fn japanese_numbers_keep_their_own_cells_down_the_column() { assert!(inks.center().x.abs() < 2.8, "{block} centred on the column: {inks:?}"); } } + +/// Upright glyphs take their cell from the font's vertical metrics: the advance down the column, +/// and where the glyph hangs from its vertical origin (VORG, or the glyph's top and top side +/// bearing). Fonts without them keep one em and the em box centre. +#[test] +fn upright_glyphs_follow_the_fonts_vertical_metrics() { + use crate::test_fonts::{VERTICAL_FAMILY, VERTICAL_TALL, vertical_font}; + for vorg in [false, true] { + let db = FontDb::with_font_dirs(vec![]); + db.add_font(vertical_font(vorg).unwrap()); + let text = format!("{VERTICAL_TALL}{VERTICAL_TALL}"); + let mut t = vertical(&text); + t.runs[0].style.font_family = VERTICAL_FAMILY.into(); + let l = layout(&db, &t); + let step = l.glyphs[1].origin.y - l.glyphs[0].origin.y; + assert!((step - 28.0).abs() < 0.01, "1.4 em down the column at 20 pt (vorg {vorg}): {step}"); + // The glyph hangs from its vertical origin, 1.1 em above the baseline: its top is + // (1.1 em − its own top) below the top of its cell. + let face = db.face(VERTICAL_FAMILY, "Regular").unwrap(); + let (_, origin) = face.vertical_glyph(face.glyph_for(VERTICAL_TALL)).unwrap(); + assert!((origin - 1100.0).abs() < 1.0, "vorg {vorg}: {origin}"); + let top = db.outline(&face, face.glyph_for(VERTICAL_TALL)).bounding_box().y0; // y-down: −yMax + let cell_top = l.glyphs[0].origin.y; + let expected = cell_top + (1.1 + top / 1000.0) * 20.0; + assert!((ink(&l, 0).y0 - expected).abs() < 0.05, "vorg {vorg}: ink top {} vs {expected}", ink(&l, 0).y0); + } + // The same glyph in the font without vertical metrics: one em, as before. + let l = layout(FontDb::global(), &vertical("§§")); + assert!((l.glyphs[1].origin.y - l.glyphs[0].origin.y - 20.0).abs() < 0.01); +}