Merge pull request #343 from imachus/pr/ja-vmetrics
Type: vertical type takes upright glyphs' cells from the font's vertical metrics
This commit is contained in:
commit
a3109c95f6
|
|
@ -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.
|
||||
|
|
@ -195,7 +195,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 | 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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -110,3 +110,67 @@ pub fn name_table(records: &[(u16, &str)]) -> Option<Vec<u8>> {
|
|||
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<Vec<u8>> {
|
||||
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<u8>, 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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue