diff --git a/crates/morpho-core/src/engine.rs b/crates/morpho-core/src/engine.rs index bf07718..e17d7e4 100644 --- a/crates/morpho-core/src/engine.rs +++ b/crates/morpho-core/src/engine.rs @@ -1712,6 +1712,7 @@ impl Engine { cap_height: self.font_metric(spec, upm, |i| i.cap_height)?, x_height: self.font_metric(spec, upm, |i| i.x_height)?, weight_class: weight, + italic: spec.italic.unwrap_or(slant.abs() > 0.5), italic_angle: -slant, copyright, description: format!("Derived from {} with Morphotype.", sources.iter().map(|i| i.full_name.as_str()).collect::>().join(" and ")), @@ -1853,8 +1854,12 @@ pub struct FontSpec { pub style: String, #[serde(default)] pub charset: Option, + /// usWeightClass; worked out from the Weight modifiers when absent. #[serde(default)] pub weight_class: Option, + /// An italic style; when absent, italic if the look slants. + #[serde(default)] + pub italic: Option, #[serde(default)] pub quality: Option, #[serde(default)] diff --git a/crates/morpho-core/src/ttf.rs b/crates/morpho-core/src/ttf.rs index 038287c..7ea0941 100644 --- a/crates/morpho-core/src/ttf.rs +++ b/crates/morpho-core/src/ttf.rs @@ -19,6 +19,9 @@ pub struct FontMeta { pub cap_height: i16, pub x_height: i16, pub weight_class: u16, + /// An italic style (fsSelection, macStyle, the name and STAT tables), + /// whether or not it leans. + pub italic: bool, /// Degrees, negative leans right (OpenType convention). pub italic_angle: f64, pub copyright: String, @@ -325,7 +328,7 @@ fn weight_name(class: u16) -> &'static str { /// STAT 1.1 with wght and ital axes and this font's place on each, so apps /// can group and name the style (Regular and Roman are elidable). fn stat_table(m: &FontMeta) -> Vec { - let italic = m.italic_angle.abs() > 0.5; + let italic = m.italic; let mut w = W(Vec::new()); w.u16(1); w.u16(1); @@ -361,7 +364,7 @@ fn name_table(m: &FontMeta) -> Vec { recs.push((2, m.style.clone())); } else { recs.push((1, format!("{} {}", m.family, m.style))); - recs.push((2, "Regular".into())); + recs.push((2, if m.italic { "Italic" } else { "Regular" }.into())); } recs.push((3, format!("{};MRPH;{}", m.version, ps_name(&m.family, &m.style)))); recs.push((4, if m.style == "Regular" { m.family.clone() } else { format!("{} {}", m.family, m.style) })); @@ -377,7 +380,7 @@ fn name_table(m: &FontMeta) -> Vec { } } // Names the STAT table refers to. - let italic = m.italic_angle.abs() > 0.5; + let italic = m.italic; recs.push((STAT_NAMES, "Weight".into())); recs.push((STAT_NAMES + 1, "Italic".into())); recs.push((STAT_NAMES + 2, weight_name(m.weight_class).into())); @@ -1078,7 +1081,7 @@ pub fn write_ttf(m: &FontMeta, glyphs: &[OutGlyph]) -> Vec { head.i16(fx1); head.i16(fy1); let bold = m.weight_class >= 600; - let italic = m.italic_angle.abs() > 0.5; + let italic = m.italic; head.u16((bold as u16) | ((italic as u16) << 1)); head.u16(8); head.i16(2); @@ -1095,8 +1098,9 @@ pub fn write_ttf(m: &FontMeta, glyphs: &[OutGlyph]) -> Vec { hhea.i16(min_rsb); hhea.i16(max_extent); let slope = m.italic_angle.to_radians(); - hhea.i16(if italic { 1000 } else { 1 }); - hhea.i16(if italic { clamp16(-slope.tan() * 1000.0) } else { 0 }); + let slanted = m.italic_angle.abs() > 0.5; + hhea.i16(if slanted { 1000 } else { 1 }); + hhea.i16(if slanted { clamp16(-slope.tan() * 1000.0) } else { 0 }); hhea.i16(0); for _ in 0..4 { hhea.i16(0); @@ -1316,6 +1320,7 @@ mod tests { cap_height: 700, x_height: 500, weight_class: 400, + italic: false, italic_angle: 0.0, copyright: "test".into(), description: String::new(), @@ -1371,6 +1376,42 @@ mod tests { assert_eq!(fam.as_deref(), Some("Test Morph")); } + #[test] + fn style_sets_weight_and_italic_flags() { + let glyphs = vec![OutGlyph { codepoint: Some('A' as u32), advance: 600.0, contours: vec![square(50.0, 500.0)] }]; + let names = |bytes: &[u8]| { + let face = Face::parse(bytes, 0).unwrap(); + let get = |id: u16| face.names().into_iter().find(|n| n.name_id == id).and_then(|n| n.to_string()); + (get(1), get(2), get(17)) + }; + let head_mac_style = |bytes: &[u8]| { + let head = Face::parse(bytes, 0).unwrap().raw_face().table(rustybuzz::ttf_parser::Tag::from_bytes(b"head")).unwrap(); + u16::from_be_bytes([head[44], head[45]]) + }; + + // An upright italic: flagged italic though it doesn't lean. + let mut m = meta(); + m.style = "Light Italic".into(); + m.weight_class = 300; + m.italic = true; + let bytes = write_ttf(&m, &glyphs); + let face = Face::parse(&bytes, 0).unwrap(); + assert_eq!(face.weight().to_number(), 300); + assert!(face.is_italic() && !face.is_bold() && !face.is_regular()); + assert_eq!(face.italic_angle(), 0.0); + assert_eq!(head_mac_style(&bytes), 0b10); + assert_eq!(names(&bytes), (Some("Test Morph Light Italic".into()), Some("Italic".into()), Some("Light Italic".into()))); + + let mut m = meta(); + m.style = "Bold".into(); + m.weight_class = 700; + let bytes = write_ttf(&m, &glyphs); + let face = Face::parse(&bytes, 0).unwrap(); + assert!(face.is_bold() && !face.is_italic()); + assert_eq!(head_mac_style(&bytes), 0b01); + assert_eq!(names(&bytes), (Some("Test Morph".into()), Some("Bold".into()), None)); + } + /// Writes kerning between `n` square glyphs and shapes every pair back. fn kerning_roundtrip(n: u32, kern: &dyn Fn(u32, u32) -> i16) -> (usize, usize) { let ch = |i: u32| char::from_u32(0x100 + i).unwrap(); diff --git a/ui/src/app/fontStyles.test.ts b/ui/src/app/fontStyles.test.ts new file mode 100644 index 0000000..6aa433a --- /dev/null +++ b/ui/src/app/fontStyles.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { canonicalStyle, isStandardStyle, lookWeight, parseStyle, STANDARD_STYLES, styleName } from './fontStyles'; +import { fontFaceCss, specOf } from './typeActions'; +import type { ModEntry } from '../engine/types'; +import type { TypeProject } from '../state/model'; + +const font = (style: string, modifiers: ModEntry[] = []): TypeProject => ({ family: 'S', style, base: 'inter', axes: {}, morph: null, modifiers, tracking: 0, charset: 'A', glyphs: {}, kerning: {}, sample: '' }); +const weightMod = (amount: number, on = true) => ({ kind: 'weight', amount, on, uid: 'm' }) as ModEntry; + +describe('font styles', () => { + it('lists nine weights, upright and italic', () => { + expect(STANDARD_STYLES).toHaveLength(18); + expect(STANDARD_STYLES.slice(0, 9)).toEqual(['Thin', 'ExtraLight', 'Light', 'Regular', 'Medium', 'SemiBold', 'Bold', 'ExtraBold', 'Black']); + expect(STANDARD_STYLES).toContain('Italic'); + expect(STANDARD_STYLES).toContain('Bold Italic'); + expect(STANDARD_STYLES).not.toContain('Regular Italic'); + }); + + it('round-trips every standard style', () => { + for (const name of STANDARD_STYLES) expect(styleName(parseStyle(name)!)).toBe(name); + }); + + it('maps names to weight and italic', () => { + expect(parseStyle('Regular')).toEqual({ weight: 400, italic: false }); + expect(parseStyle('Italic')).toEqual({ weight: 400, italic: true }); + expect(parseStyle('ExtraLight')).toEqual({ weight: 200, italic: false }); + expect(parseStyle('Extra Bold Italic')).toEqual({ weight: 800, italic: true }); + expect(parseStyle('semi-bold')).toEqual({ weight: 600, italic: false }); + expect(parseStyle('Demibold')).toEqual({ weight: 600, italic: false }); + expect(parseStyle('Heavy Oblique')).toEqual({ weight: 900, italic: true }); + expect(parseStyle('Hairline')).toEqual({ weight: 100, italic: false }); + expect(parseStyle('Condensed Bold')).toEqual({ weight: 700, italic: false }); + expect(parseStyle('Display')).toBeNull(); + expect(parseStyle('')).toBeNull(); + }); + + it('spells a base font style the standard way, keeping custom ones', () => { + expect(canonicalStyle('Semi Bold')).toBe('SemiBold'); + expect(canonicalStyle('Regular Italic')).toBe('Italic'); + expect(canonicalStyle('bold italic')).toBe('Bold Italic'); + expect(canonicalStyle('Condensed Bold')).toBe('Condensed Bold'); + expect(canonicalStyle('Display')).toBe('Display'); + expect(isStandardStyle('Display')).toBe(false); + }); + + it('reckons a look weight like the engine', () => { + expect(lookWeight([])).toBe(400); + expect(lookWeight([weightMod(0.075)])).toBe(700); + expect(lookWeight([weightMod(0.075, false)])).toBe(400); + expect(lookWeight([weightMod(-1)])).toBe(100); + expect(lookWeight([weightMod(1)])).toBe(900); + expect(lookWeight([weightMod(0.05)], 300)).toBe(500); + }); +}); + +describe('style in the export', () => { + it('sends the weight class and italic flag of a named style', () => { + expect(specOf(font('Light Italic'))).toMatchObject({ style: 'Light Italic', weightClass: 300, italic: true }); + expect(specOf(font('Bold'))).toMatchObject({ weightClass: 700, italic: false }); + }); + + it('leaves a custom style to the look', () => { + const spec = specOf(font('Display')); + expect(spec.weightClass).toBeUndefined(); + expect(spec.italic).toBeUndefined(); + }); + + it('writes @font-face weight and style', () => { + expect(fontFaceCss(font('ExtraLight'), 'S')).toContain('font-weight: 200;'); + expect(fontFaceCss(font('ExtraBold Italic'), 'S')).toMatch(/font-weight: 800;[\s\S]*font-style: italic;/); + expect(fontFaceCss(font('Display', [weightMod(0.075)]), 'S')).toContain('font-weight: 700;'); + }); +}); diff --git a/ui/src/app/fontStyles.ts b/ui/src/app/fontStyles.ts new file mode 100644 index 0000000..b459685 --- /dev/null +++ b/ui/src/app/fontStyles.ts @@ -0,0 +1,75 @@ +// Font style names (the subfamily, "Bold Italic") and the weight class and +// italic flag they stand for in an exported font. +import type { ModEntry } from '../engine/types'; + +export const WEIGHTS: { weight: number; name: string }[] = [ + { weight: 100, name: 'Thin' }, + { weight: 200, name: 'ExtraLight' }, + { weight: 300, name: 'Light' }, + { weight: 400, name: 'Regular' }, + { weight: 500, name: 'Medium' }, + { weight: 600, name: 'SemiBold' }, + { weight: 700, name: 'Bold' }, + { weight: 800, name: 'ExtraBold' }, + { weight: 900, name: 'Black' }, +]; + +export interface FontStyle { + weight: number; + italic: boolean; +} + +/** The standard name of a weight and slope: "Regular", "Italic", "Light Italic". */ +export function styleName({ weight, italic }: FontStyle): string { + const w = WEIGHTS.find((x) => x.weight === weight)?.name ?? 'Regular'; + if (!italic) return w; + return w === 'Regular' ? 'Italic' : `${w} Italic`; +} + +/** Every standard style, uprights first. */ +export const STANDARD_STYLES: string[] = [false, true].flatMap((italic) => WEIGHTS.map((w) => styleName({ weight: w.weight, italic }))); + +export const isStandardStyle = (name: string) => STANDARD_STYLES.includes(name); + +// Longer words first, so "ExtraBold" isn't read as Bold. +const WEIGHT_WORDS: [RegExp, number][] = [ + [/(extra|ultra)light/, 200], + [/(extra|ultra)bold/, 800], + [/(semi|demi)bold/, 600], + [/thin|hairline/, 100], + [/light/, 300], + [/medium/, 500], + [/bold/, 700], + [/black|heavy/, 900], + [/regular|normal|book|roman/, 400], +]; + +/** + * The weight and slope a style name stands for, also for spellings like + * "Semi Bold" or "Heavy Oblique". Null when it names neither (a custom + * style such as "Display"). + */ +export function parseStyle(name: string): FontStyle | null { + const s = name.toLowerCase().replace(/[\s_-]+/g, ''); + const italic = /italic|oblique/.test(s); + const weight = WEIGHT_WORDS.find(([re]) => re.test(s))?.[1]; + if (weight === undefined && !italic) return null; + return { weight: weight ?? 400, italic }; +} + +/** The standard spelling of a style name ("semi bold italic" → "SemiBold Italic"), or the name as it is. */ +export function canonicalStyle(name: string): string { + const p = parseStyle(name); + const compact = (s: string) => s.toLowerCase().replace(/[\s_-]+/g, '').replace(/^regular(?=italic)/, ''); + return p && compact(styleName(p)) === compact(name) ? styleName(p) : name.trim(); +} + +/** + * The weight a font look adds up to, as the engine reckons it when the + * style doesn't say: `from` (400) plus 4000 per em of Weight, to the + * nearest 100. + */ +export function lookWeight(mods: ModEntry[], from = 400): number { + const w = mods.reduce((acc, m) => (m.on && m.kind === 'weight' ? acc + m.amount * 4000 : acc), from); + return Math.min(9, Math.max(1, Math.round(w / 100))) * 100; +} diff --git a/ui/src/app/typeActions.ts b/ui/src/app/typeActions.ts index b5a80af..92abd1c 100644 --- a/ui/src/app/typeActions.ts +++ b/ui/src/app/typeActions.ts @@ -8,6 +8,7 @@ import { allLayers } from '../state/tree'; import { edit, store, toast, undo } from '../state/store'; import { addShape, errorText, primaryNode, registerFontEntry, slug } from './actions'; import { zip } from './zip'; +import { canonicalStyle, lookWeight, parseStyle } from './fontStyles'; import { addClass, deleteClass, joinClass, leaveClass, resolveKerning, setClassMembers, setEntry } from './kerning'; export const CHARSETS = { @@ -48,13 +49,31 @@ export function newFontDefaults(): NewFontOptions { }; } +/** A font's own style name ("Black", "SemiBold"), standard spelling where it has one. */ +export function baseStyle(fontId: string): string { + const style = store.get().fonts.find((f) => f.id === fontId)?.info.style.trim(); + return style ? canonicalStyle(style) : 'Regular'; +} + +/** + * Reshape a different font. A style still matching the old base's follows + * the new one; a style the user chose stays. + */ +export function setTypeBase(fontId: string) { + editType((tp) => { + if (!tp.blank && tp.style === baseStyle(tp.base)) tp.style = baseStyle(fontId); + tp.base = fontId; + tp.axes = {}; + }); +} + /** Start a font project. */ export function createTypeProject(o: NewFontOptions): TypeProject { const tp: TypeProject = { id: uid('f'), blank: o.blank || undefined, family: o.family.trim() || 'Untitled Sans', - style: 'Regular', + style: o.blank ? 'Regular' : baseStyle(o.base), base: o.base, axes: o.axes ?? {}, morph: o.blank ? null : (o.morph ?? null), @@ -99,6 +118,10 @@ export function specOf(t: TypeProject, opts: { quality?: number; format?: 'ttf' modifiers: t.modifiers, family: t.family, style: t.style, + // A named style sets the weight class and italic flags; a custom name + // leaves them to the look. + weightClass: parseStyle(t.style)?.weight, + italic: parseStyle(t.style)?.italic, charset: t.charset, glyphs, tracking: t.tracking, @@ -228,14 +251,14 @@ export async function exportFontFile(format: 'ttf' | 'woff') { } export function fontFaceCss(t: TypeProject, base: string) { - const weight = t.style.match(/thin|light|regular|medium|semi|bold|black|heavy/i)?.[0]?.toLowerCase(); - const w = { thin: 100, light: 300, regular: 400, medium: 500, semi: 600, bold: 700, black: 900, heavy: 900 }[weight ?? 'regular'] ?? 400; + const style = parseStyle(t.style); + const w = style?.weight ?? lookWeight(t.modifiers); return `@font-face { font-family: "${t.family}"; src: url("${base}.woff") format("woff"), url("${base}.ttf") format("truetype"); font-weight: ${w}; - font-style: ${/italic|oblique/i.test(t.style) ? 'italic' : 'normal'}; + font-style: ${style?.italic ? 'italic' : 'normal'}; font-display: swap; } `; diff --git a/ui/src/components/type/StylePicker.tsx b/ui/src/components/type/StylePicker.tsx new file mode 100644 index 0000000..be88d1a --- /dev/null +++ b/ui/src/components/type/StylePicker.tsx @@ -0,0 +1,106 @@ +// The font's style name: the standard styles by weight, upright and italic, +// or a name of your own. A standard style sets the weight class and italic +// flags apps use to group a family. +import { useState } from 'react'; +import { isStandardStyle, lookWeight, parseStyle, styleName, WEIGHTS } from '../../app/fontStyles'; +import type { ModEntry } from '../../engine/types'; +import { Icon } from '../icons'; +import { Menu } from '../ui'; + +export function StylePicker({ value, onChange, base, mods }: { value: string; onChange: (style: string) => void; base?: string; mods: ModEntry[] }) { + const [naming, setNaming] = useState(false); + const custom = naming || !isStandardStyle(value); + const parsed = parseStyle(value); + const look = lookWeight(mods); + // A look that thickens or thins the base suggests the style it lands on. + const weighted = lookWeight(mods, parseStyle(base ?? '')?.weight ?? 400); + const thickens = mods.some((m) => m.on && m.kind === 'weight' && m.amount !== 0); + const suggest = parsed && !custom && thickens && Math.abs(weighted - parsed.weight) >= 200 ? styleName({ weight: weighted, italic: parsed.italic }) : null; + const item = (name: string, close: () => void) => ( + + ); + return ( + <> +
+ Style + + {custom ? 'Custom' : value} + {parsed && {parsed.weight}} + + + } + > + {(close) => ( +
+
+ + Upright + Italic + {WEIGHTS.map((w) => ( +
+ {w.weight} + {item(styleName({ weight: w.weight, italic: false }), close)} + {item(styleName({ weight: w.weight, italic: true }), close)} +
+ ))} +
+
+ +
+ )} +
+
+ {custom && ( + + )} +

+ {!parsed + ? `A custom name. Its weight comes from the look (${look}) and it's italic if the look slants.` + : `${custom ? 'Reads as' : 'Exports as'} weight ${parsed.weight}, ${parsed.italic ? 'italic' : 'upright'}. Apps group a family's styles by these.`} + {suggest && ( + <> + {' '} + Your look is closer to {suggest}.{' '} + + + )} +

+ + ); +} diff --git a/ui/src/components/type/TypeMode.tsx b/ui/src/components/type/TypeMode.tsx index 900b6f2..f813755 100644 --- a/ui/src/components/type/TypeMode.tsx +++ b/ui/src/components/type/TypeMode.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { importImage } from '../../app/actions'; -import { addCharacters, addGlyphPart, CHARSETS, closeTypeProject, customFontId, customizeGlyph, editType, exportFontFile, exportWebKit, installOnMac, removeCharacter, resetGlyph, specOf } from '../../app/typeActions'; +import { addCharacters, addGlyphPart, baseStyle, CHARSETS, closeTypeProject, customFontId, customizeGlyph, editType, exportFontFile, exportWebKit, installOnMac, removeCharacter, resetGlyph, setTypeBase, specOf } from '../../app/typeActions'; +import { StylePicker } from './StylePicker'; import { TypeStart } from './TypeStart'; import { GlyphLayers, GlyphLook, GlyphPartPanel, PartsOverlay } from './GlyphLayers'; import { TYPE_TERMS } from './TypeGuide'; @@ -550,13 +551,10 @@ function FontTab({ t }: { t: TypeProject }) {

- + editType((tp) => (tp.style = v), 'style')} />
{t.blank ? 'Lines from' : 'Base font'} - id && editType((tp) => ((tp.base = id), (tp.axes = {})))} /> + id && setTypeBase(id)} />
{!t.blank && ( <> diff --git a/ui/src/components/type/UseFontDialog.tsx b/ui/src/components/type/UseFontDialog.tsx index 81f4c9d..f5384ec 100644 --- a/ui/src/components/type/UseFontDialog.tsx +++ b/ui/src/components/type/UseFontDialog.tsx @@ -2,6 +2,7 @@ // from its base, and choose what happens to text set in the base font. import { useState } from 'react'; import { customFontId, editType, fontChanges, nameClash, useInDesign, type UseMode } from '../../app/typeActions'; +import { STANDARD_STYLES } from '../../app/fontStyles'; import { allLayers } from '../../state/tree'; import { store, useStore } from '../../state/store'; import { Icon } from '../icons'; @@ -58,7 +59,12 @@ export function UseFontDialog() { {clash &&

{clash}

} diff --git a/ui/src/components/ui.tsx b/ui/src/components/ui.tsx index 18d05bc..fc8112a 100644 --- a/ui/src/components/ui.tsx +++ b/ui/src/components/ui.tsx @@ -118,7 +118,7 @@ export function Popover({ // Arrow keys move between menu items. const onKeyDown = (e: React.KeyboardEvent) => { if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; - const items = Array.from(ref.current?.querySelectorAll('[role=menuitem]:not([disabled])') ?? []); + const items = Array.from(ref.current?.querySelectorAll('[role^=menuitem]:not([disabled])') ?? []); if (!items.length) return; e.preventDefault(); const i = items.indexOf(document.activeElement as HTMLElement); diff --git a/ui/src/engine/types.ts b/ui/src/engine/types.ts index 39362ec..9f9726f 100644 --- a/ui/src/engine/types.ts +++ b/ui/src/engine/types.ts @@ -360,7 +360,10 @@ export interface FontSpec { family: string; style: string; charset: string | null; + /** usWeightClass; from the look's Weight modifiers when absent. */ weightClass?: number; + /** An italic style; when absent, italic if the look slants. */ + italic?: boolean; quality?: number; glyphs?: GlyphEdit[]; tracking?: number; diff --git a/ui/src/styles.css b/ui/src/styles.css index 01d8809..74741ea 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -5261,3 +5261,42 @@ body.tl-dragging * { .button-row { gap: 6px; } + +/* Type mode style picker: weights down, upright and italic across. */ +.style-select .style-num { + color: var(--dk-label); + font-family: var(--mono); + font-size: 11px; +} +.style-menu .style-grid { + display: grid; + grid-template-columns: 34px 1fr 1fr; + column-gap: 2px; +} +.style-grid .style-row { + display: contents; +} +.style-grid > .menu-label { + padding-left: 30px; +} +.style-weight { + align-self: center; + justify-self: end; + padding-right: 4px; + color: var(--faint); + font-family: var(--mono); + font-size: 11px; +} +.style-base { + color: var(--faint); + font-size: 10.5px; +} +.menu-item:hover .style-base { + color: inherit; +} +.text-field.style-name { + width: auto; + min-width: 0; + max-width: 65%; + text-align: right; +}