Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/morpho-core/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>().join(" and ")),
Expand Down Expand Up @@ -1853,8 +1854,12 @@ pub struct FontSpec {
pub style: String,
#[serde(default)]
pub charset: Option<String>,
/// usWeightClass; worked out from the Weight modifiers when absent.
#[serde(default)]
pub weight_class: Option<u16>,
/// An italic style; when absent, italic if the look slants.
#[serde(default)]
pub italic: Option<bool>,
#[serde(default)]
pub quality: Option<f64>,
#[serde(default)]
Expand Down
53 changes: 47 additions & 6 deletions crates/morpho-core/src/ttf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<u8> {
let italic = m.italic_angle.abs() > 0.5;
let italic = m.italic;
let mut w = W(Vec::new());
w.u16(1);
w.u16(1);
Expand Down Expand Up @@ -361,7 +364,7 @@ fn name_table(m: &FontMeta) -> Vec<u8> {
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) }));
Expand All @@ -377,7 +380,7 @@ fn name_table(m: &FontMeta) -> Vec<u8> {
}
}
// 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()));
Expand Down Expand Up @@ -1078,7 +1081,7 @@ pub fn write_ttf(m: &FontMeta, glyphs: &[OutGlyph]) -> Vec<u8> {
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);
Expand All @@ -1095,8 +1098,9 @@ pub fn write_ttf(m: &FontMeta, glyphs: &[OutGlyph]) -> Vec<u8> {
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);
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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();
Expand Down
73 changes: 73 additions & 0 deletions ui/src/app/fontStyles.test.ts
Original file line number Diff line number Diff line change
@@ -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;');
});
});
75 changes: 75 additions & 0 deletions ui/src/app/fontStyles.ts
Original file line number Diff line number Diff line change
@@ -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;
}
31 changes: 27 additions & 4 deletions ui/src/app/typeActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
`;
Expand Down
Loading
Loading