From 0f55f76bdd3fb914fc441ee43bcc72b953a2a007 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 10:43:34 +0000 Subject: [PATCH 01/41] Add variable font support: axes, sources, interpolation, and preview slider Expose Axis, Location, and Source IR types via ts-rs and NAPI bindings. Add TS-side glyph interpolation engine with compatibility checking and multilinear blending. Wire up VariationPanel component with per-axis sliders that interpolate in real-time and push results through the existing snapshot rendering pipeline. https://claude.ai/code/session_01EFbSfzoWykbgJ3mLFpw4wy --- ROADMAP.md | 8 +- .../src/renderer/src/bridge/NativeBridge.ts | 23 ++ .../renderer/src/components/GlyphSidebar.tsx | 5 + .../src/components/VariationPanel.tsx | 180 ++++++++++++++++ .../src/lib/interpolation/interpolate.test.ts | 196 ++++++++++++++++++ .../src/lib/interpolation/interpolate.ts | 191 +++++++++++++++++ crates/shift-core/src/lib.rs | 6 +- crates/shift-ir/src/axis.rs | 8 +- crates/shift-ir/src/source.rs | 7 +- crates/shift-node/index.d.ts | 8 + crates/shift-node/src/font_engine.rs | 87 +++++++- packages/types/src/font.ts | 3 + packages/types/src/generated/Axis.ts | 3 + packages/types/src/generated/Location.ts | 3 + packages/types/src/generated/Source.ts | 4 + packages/types/src/generated/index.ts | 3 + packages/types/src/index.ts | 3 + scripts/oxlint/shift-plugin.mjs | 2 + 18 files changed, 729 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/VariationPanel.tsx create mode 100644 apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts create mode 100644 packages/types/src/generated/Axis.ts create mode 100644 packages/types/src/generated/Location.ts create mode 100644 packages/types/src/generated/Source.ts diff --git a/ROADMAP.md b/ROADMAP.md index b6a296c5..b44251eb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -362,20 +362,20 @@ **Designspace Support** - [ ] Load `.designspace` files -- [ ] Parse axis definitions (wght, wdth, ital, custom) +- [x] Parse axis definitions (wght, wdth, ital, custom) - [ ] Named instances **Masters Editing** -- [ ] Master list panel +- [x] Master list panel - [ ] Switch between masters - [ ] Add/remove masters - [ ] Copy glyph between masters **Interpolation** -- [ ] Compatibility checker (point/contour count) -- [ ] Interpolation preview slider +- [x] Compatibility checker (point/contour count) +- [x] Interpolation preview slider - [ ] Intermediate master insertion - [ ] Extrapolation warning diff --git a/apps/desktop/src/renderer/src/bridge/NativeBridge.ts b/apps/desktop/src/renderer/src/bridge/NativeBridge.ts index c0b0097b..c5aec4b1 100644 --- a/apps/desktop/src/renderer/src/bridge/NativeBridge.ts +++ b/apps/desktop/src/renderer/src/bridge/NativeBridge.ts @@ -6,7 +6,10 @@ import type { ContourId, Point2D, AnchorId, + Axis, + Source, } from "@shift/types"; +import type { MasterSnapshot } from "@/lib/interpolation/interpolate"; import { signal, type WritableSignal, type Signal } from "@/lib/reactive/signal"; import type { Bounds } from "@shift/geo"; import { Bounds as BoundsUtil } from "@shift/geo"; @@ -138,6 +141,26 @@ export class NativeBridge { return JSON.parse(payload) as CompositeComponentsPayload; } + // ── Variable Font Queries ── + + isVariable(): boolean { + return this.#raw.isVariable(); + } + + getAxes(): Axis[] { + return JSON.parse(this.#raw.getAxes()) as Axis[]; + } + + getSources(): Source[] { + return JSON.parse(this.#raw.getSources()) as Source[]; + } + + getGlyphMasterSnapshots(glyphName: string): MasterSnapshot[] | null { + const json = this.#raw.getGlyphMasterSnapshots(glyphName); + if (!json) return null; + return JSON.parse(json) as MasterSnapshot[]; + } + getSnapshot(): GlyphSnapshot { return JSON.parse(this.#raw.getSnapshotData()) as GlyphSnapshot; } diff --git a/apps/desktop/src/renderer/src/components/GlyphSidebar.tsx b/apps/desktop/src/renderer/src/components/GlyphSidebar.tsx index 6dc5faf2..f1974374 100644 --- a/apps/desktop/src/renderer/src/components/GlyphSidebar.tsx +++ b/apps/desktop/src/renderer/src/components/GlyphSidebar.tsx @@ -9,6 +9,7 @@ import { useSignalEffect } from "@/hooks/useSignalEffect"; import { GlyphSection } from "./sidebar-right/GlyphSection"; import { AnchorSection } from "./sidebar-right/AnchorSection"; import { BooleanOps } from "./BooleanOps"; +import { VariationPanel } from "./VariationPanel"; export const GlyphSidebar = () => { const editor = getEditor(); @@ -42,6 +43,10 @@ export const GlyphSidebar = () => { +
+ +
+ {hasPointSelection && (
diff --git a/apps/desktop/src/renderer/src/components/VariationPanel.tsx b/apps/desktop/src/renderer/src/components/VariationPanel.tsx new file mode 100644 index 00000000..a22300ed --- /dev/null +++ b/apps/desktop/src/renderer/src/components/VariationPanel.tsx @@ -0,0 +1,180 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { Axis } from "@shift/types"; +import { SidebarSection } from "./sidebar-right/SidebarSection"; +import { getEditor } from "@/store/store"; +import { useSignalState } from "@/lib/reactive"; +import { interpolateGlyph, type MasterSnapshot } from "@/lib/interpolation/interpolate"; + +/** Variation axis slider panel — shown when a variable font is loaded. */ +export const VariationPanel = () => { + const editor = getEditor(); + const engine = editor.fontEngine; + const fontLoaded = useSignalState(engine.$fontLoaded); + + const [axes, setAxes] = useState([]); + const [location, setLocation] = useState>({}); + const mastersRef = useRef(null); + const [isInterpolating, setIsInterpolating] = useState(false); + const [editingGlyph, setEditingGlyph] = useState(null); + + // Load axes when font is loaded + useEffect(() => { + if (!fontLoaded || !engine.isVariable()) { + setAxes([]); + return; + } + + const fontAxes = engine.getAxes(); + setAxes(fontAxes); + + const defaults: Record = {}; + for (const axis of fontAxes) { + defaults[axis.tag] = axis.default; + } + setLocation(defaults); + }, [fontLoaded, engine]); + + // Track the current glyph and reload masters when it changes + useEffect(() => { + const glyphName = engine.getEditingGlyphName(); + setEditingGlyph(glyphName); + }); + + useEffect(() => { + if (axes.length === 0 || !editingGlyph) { + mastersRef.current = null; + return; + } + + mastersRef.current = engine.getGlyphMasterSnapshots(editingGlyph); + setIsInterpolating(false); + }, [axes, editingGlyph, engine]); + + const handleAxisChange = useCallback( + (tag: string, value: number) => { + const newLocation = { ...location, [tag]: value }; + setLocation(newLocation); + + const masters = mastersRef.current; + if (!masters || masters.length < 2) return; + + const result = interpolateGlyph(masters, axes, newLocation); + if (!result) return; + + setIsInterpolating(true); + engine.emitGlyph(result); + }, + [location, axes, engine], + ); + + const handleMasterClick = useCallback( + (sourceName: string) => { + const masters = mastersRef.current; + if (!masters) return; + + const master = masters.find((m) => m.sourceName === sourceName); + if (!master) return; + + const newLocation: Record = {}; + for (const axis of axes) { + newLocation[axis.tag] = master.location.values[axis.tag] ?? axis.default; + } + setLocation(newLocation); + + // Show this master's snapshot directly + setIsInterpolating(true); + engine.emitGlyph(master.snapshot); + }, + [axes, engine], + ); + + const handleResetToSession = useCallback(() => { + if (!isInterpolating) return; + + setIsInterpolating(false); + const sessionGlyph = engine.getSessionGlyph(); + if (sessionGlyph) { + engine.emitGlyph(sessionGlyph); + } + + const defaults: Record = {}; + for (const axis of axes) { + defaults[axis.tag] = axis.default; + } + setLocation(defaults); + }, [isInterpolating, engine, axes]); + + if (axes.length === 0) return null; + + const masterNames = mastersRef.current?.map((m) => m.sourceName) ?? []; + + return ( + +
+ {axes.map((axis) => ( + handleAxisChange(axis.tag, value)} + /> + ))} + {masterNames.length > 0 && ( +
+ {masterNames.map((name) => ( + + ))} +
+ )} + {isInterpolating && ( + + )} +
+
+ ); +}; + +interface AxisSliderProps { + axis: Axis; + value: number; + onChange: (value: number) => void; +} + +const AxisSlider = ({ axis, value, onChange }: AxisSliderProps) => { + const displayValue = Math.round(value); + + return ( +
+
+ {axis.name} + {displayValue} +
+ onChange(Number(e.target.value))} + className="w-full h-1.5 bg-[#e0e0e0] rounded-full appearance-none cursor-pointer + [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:h-3.5 + [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[#333] + [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-2 + [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:shadow-sm" + /> +
+ ); +}; diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts new file mode 100644 index 00000000..83c0c0af --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect } from "vitest"; +import { + interpolateGlyph, + normalizeAxisValue, + checkCompatibility, + type MasterSnapshot, +} from "./interpolate"; +import type { Axis, GlyphSnapshot, ContourSnapshot } from "@shift/types"; + +function makeAxis(overrides?: Partial): Axis { + return { + tag: "wght", + name: "Weight", + minimum: 100, + default: 400, + maximum: 900, + hidden: false, + ...overrides, + }; +} + +function makeContour(points: Array<{ x: number; y: number }>): ContourSnapshot { + return { + id: "c1" as never, + closed: true, + points: points.map((p, i) => ({ + id: `p${i}` as never, + x: p.x, + y: p.y, + pointType: "onCurve" as never, + smooth: false, + })), + }; +} + +function makeSnapshot(contours: ContourSnapshot[], xAdvance: number): GlyphSnapshot { + return { + unicode: 65, + name: "A", + xAdvance, + contours, + anchors: [], + compositeContours: [], + activeContourId: null, + }; +} + +function makeMaster( + name: string, + wght: number, + contour: ContourSnapshot, + xAdvance: number, +): MasterSnapshot { + return { + sourceId: name, + sourceName: name, + location: { values: { wght } }, + snapshot: makeSnapshot([contour], xAdvance), + }; +} + +describe("normalizeAxisValue", () => { + const axis = makeAxis(); + + it("returns 0 at default", () => { + expect(normalizeAxisValue(400, axis)).toBe(0); + }); + + it("returns -1 at minimum", () => { + expect(normalizeAxisValue(100, axis)).toBeCloseTo(-1); + }); + + it("returns 1 at maximum", () => { + expect(normalizeAxisValue(900, axis)).toBeCloseTo(1); + }); + + it("returns -0.5 at midpoint below default", () => { + expect(normalizeAxisValue(250, axis)).toBeCloseTo(-0.5); + }); +}); + +describe("checkCompatibility", () => { + it("returns null for compatible masters", () => { + const light = makeMaster( + "Light", + 100, + makeContour([ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + ]), + 500, + ); + const bold = makeMaster( + "Bold", + 900, + makeContour([ + { x: 10, y: 0 }, + { x: 110, y: 0 }, + ]), + 600, + ); + expect(checkCompatibility([light, bold])).toBeNull(); + }); + + it("reports contour count mismatch", () => { + const light = makeMaster("Light", 100, makeContour([{ x: 0, y: 0 }]), 500); + const bold: MasterSnapshot = { + ...makeMaster("Bold", 900, makeContour([{ x: 0, y: 0 }]), 600), + snapshot: makeSnapshot([makeContour([{ x: 0, y: 0 }]), makeContour([{ x: 50, y: 50 }])], 600), + }; + expect(checkCompatibility([light, bold])).toContain("2 contours"); + }); + + it("reports point count mismatch", () => { + const light = makeMaster("Light", 100, makeContour([{ x: 0, y: 0 }]), 500); + const bold = makeMaster( + "Bold", + 900, + makeContour([ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + ]), + 600, + ); + expect(checkCompatibility([light, bold])).toContain("points"); + }); +}); + +describe("interpolateGlyph", () => { + const axes = [makeAxis()]; + + it("returns the single master's snapshot when only one master", () => { + const master = makeMaster("Regular", 400, makeContour([{ x: 100, y: 200 }]), 500); + const result = interpolateGlyph([master], axes, { wght: 400 }); + expect(result).toBe(master.snapshot); + }); + + it("interpolates at midpoint between two masters", () => { + const light = makeMaster("Light", 100, makeContour([{ x: 100, y: 200 }]), 500); + const bold = makeMaster("Bold", 900, makeContour([{ x: 200, y: 400 }]), 700); + + const result = interpolateGlyph([light, bold], axes, { wght: 500 }); + + expect(result).not.toBeNull(); + expect(result!.contours[0].points[0].x).toBeCloseTo(150); + expect(result!.contours[0].points[0].y).toBeCloseTo(300); + expect(result!.xAdvance).toBeCloseTo(600); + }); + + it("returns master A at master A location", () => { + const light = makeMaster("Light", 100, makeContour([{ x: 100, y: 200 }]), 500); + const bold = makeMaster("Bold", 900, makeContour([{ x: 200, y: 400 }]), 700); + + const result = interpolateGlyph([light, bold], axes, { wght: 100 }); + + expect(result!.contours[0].points[0].x).toBeCloseTo(100); + expect(result!.contours[0].points[0].y).toBeCloseTo(200); + }); + + it("returns master B at master B location", () => { + const light = makeMaster("Light", 100, makeContour([{ x: 100, y: 200 }]), 500); + const bold = makeMaster("Bold", 900, makeContour([{ x: 200, y: 400 }]), 700); + + const result = interpolateGlyph([light, bold], axes, { wght: 900 }); + + expect(result!.contours[0].points[0].x).toBeCloseTo(200); + expect(result!.contours[0].points[0].y).toBeCloseTo(400); + }); + + it("returns null for incompatible masters", () => { + const light = makeMaster("Light", 100, makeContour([{ x: 0, y: 0 }]), 500); + const bold = makeMaster( + "Bold", + 900, + makeContour([ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + ]), + 600, + ); + + const result = interpolateGlyph([light, bold], axes, { wght: 500 }); + expect(result).toBeNull(); + }); + + it("preserves point metadata from reference master", () => { + const light = makeMaster("Light", 100, makeContour([{ x: 100, y: 200 }]), 500); + const bold = makeMaster("Bold", 900, makeContour([{ x: 200, y: 400 }]), 700); + + const result = interpolateGlyph([light, bold], axes, { wght: 500 }); + + expect(result!.contours[0].points[0].id).toBe(light.snapshot.contours[0].points[0].id); + expect(result!.contours[0].points[0].pointType).toBe("onCurve"); + expect(result!.contours[0].id).toBe(light.snapshot.contours[0].id); + }); +}); diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts new file mode 100644 index 00000000..66c9c944 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts @@ -0,0 +1,191 @@ +import type { Axis, GlyphSnapshot, ContourSnapshot, PointSnapshot } from "@shift/types"; + +/** A single master's glyph data with its design-space location. */ +export interface MasterSnapshot { + sourceId: string; + sourceName: string; + location: { values: { [key in string]?: number } }; + snapshot: GlyphSnapshot; +} + +/** Normalize an axis value to the range [-1, 1]. */ +export function normalizeAxisValue(value: number, axis: Axis): number { + if (value < axis.default) { + const range = axis.default - axis.minimum; + return range < Number.EPSILON ? 0 : (value - axis.default) / range; + } + + if (value > axis.default) { + const range = axis.maximum - axis.default; + return range < Number.EPSILON ? 0 : (value - axis.default) / range; + } + + return 0; +} + +/** + * Check that all masters have compatible contour structure. + * Returns null if compatible, or a description of the first incompatibility. + */ +export function checkCompatibility(masters: MasterSnapshot[]): string | null { + if (masters.length < 2) return null; + + const ref = masters[0].snapshot; + + for (let m = 1; m < masters.length; m++) { + const other = masters[m].snapshot; + + if (ref.contours.length !== other.contours.length) { + return `Master "${masters[m].sourceName}" has ${other.contours.length} contours, expected ${ref.contours.length}`; + } + + for (let c = 0; c < ref.contours.length; c++) { + const refContour = ref.contours[c]; + const otherContour = other.contours[c]; + + if (refContour.points.length !== otherContour.points.length) { + return `Master "${masters[m].sourceName}" contour ${c}: ${otherContour.points.length} points, expected ${refContour.points.length}`; + } + } + } + + return null; +} + +/** + * Interpolate a glyph at a target design-space location. + * + * For 2 masters on 1 axis this is simple linear interpolation: + * result = masterA * (1 - t) + masterB * t + * + * For N masters on M axes we compute per-master scalar weights + * using bilinear/multilinear interpolation, then blend. + */ +export function interpolateGlyph( + masters: MasterSnapshot[], + axes: Axis[], + target: Record, +): GlyphSnapshot | null { + if (masters.length === 0) return null; + if (masters.length === 1) return masters[0].snapshot; + + const error = checkCompatibility(masters); + if (error) return null; + + const weights = computeMasterWeights(masters, axes, target); + + const ref = masters[0].snapshot; + + const contours: ContourSnapshot[] = ref.contours.map((refContour, ci) => ({ + id: refContour.id, + closed: refContour.closed, + points: refContour.points.map((refPoint, pi) => + blendPoint( + masters.map((m) => m.snapshot.contours[ci].points[pi]), + weights, + refPoint, + ), + ), + })); + + let xAdvance = 0; + for (let i = 0; i < masters.length; i++) { + xAdvance += masters[i].snapshot.xAdvance * weights[i]; + } + + return { + ...ref, + xAdvance, + contours, + }; +} + +function blendPoint(points: PointSnapshot[], weights: number[], ref: PointSnapshot): PointSnapshot { + let x = 0; + let y = 0; + + for (let i = 0; i < points.length; i++) { + x += points[i].x * weights[i]; + y += points[i].y * weights[i]; + } + + return { ...ref, x, y }; +} + +/** + * Compute scalar weights for each master given a target location. + * + * Uses bilinear/multilinear interpolation in normalized design space. + * For the common 2-master / 1-axis case this reduces to simple lerp. + */ +function computeMasterWeights( + masters: MasterSnapshot[], + axes: Axis[], + target: Record, +): number[] { + if (masters.length === 2 && axes.length === 1) { + return twoMasterWeights(masters, axes[0], target); + } + + return generalWeights(masters, axes, target); +} + +/** Fast path for the common 2-master / 1-axis case. */ +function twoMasterWeights( + masters: MasterSnapshot[], + axis: Axis, + target: Record, +): number[] { + const tag = axis.tag; + const targetVal = target[tag] ?? axis.default; + + const valA = masters[0].location.values[tag] ?? axis.default; + const valB = masters[1].location.values[tag] ?? axis.default; + + const range = valB - valA; + if (Math.abs(range) < Number.EPSILON) return [0.5, 0.5]; + + const t = Math.max(0, Math.min(1, (targetVal - valA) / range)); + return [1 - t, t]; +} + +/** + * General N-master / M-axis interpolation using inverse-distance weighting + * in normalized design space. This is a reasonable approximation for + * arbitrary master configurations. + */ +function generalWeights( + masters: MasterSnapshot[], + axes: Axis[], + target: Record, +): number[] { + const normalizedTarget: Record = {}; + for (const axis of axes) { + normalizedTarget[axis.tag] = normalizeAxisValue(target[axis.tag] ?? axis.default, axis); + } + + const distances: number[] = masters.map((master) => { + let dist = 0; + for (const axis of axes) { + const masterVal = master.location.values[axis.tag] ?? axis.default; + const nMaster = normalizeAxisValue(masterVal, axis); + const diff = normalizedTarget[axis.tag] - nMaster; + dist += diff * diff; + } + return Math.sqrt(dist); + }); + + // Check for exact match + for (let i = 0; i < distances.length; i++) { + if (distances[i] < 1e-10) { + const weights = Array.from({ length: masters.length }, () => 0); + weights[i] = 1; + return weights; + } + } + + // Inverse distance weighting + const invDist = distances.map((d) => 1 / d); + const sum = invDist.reduce((a, b) => a + b, 0); + return invDist.map((w) => w / sum); +} diff --git a/crates/shift-core/src/lib.rs b/crates/shift-core/src/lib.rs index f4d1aa0b..6ad9a531 100644 --- a/crates/shift-core/src/lib.rs +++ b/crates/shift-core/src/lib.rs @@ -9,9 +9,9 @@ pub mod snapshot; pub mod vec2; pub use shift_ir::{ - Anchor, AnchorId, BooleanOp, Contour, ContourId, CurveSegment, CurveSegmentIter, Font, - FontMetadata, FontMetrics, Glyph, GlyphLayer, GlyphName, GuidelineId, LayerId, Point, PointId, - PointType, Transform, + Anchor, AnchorId, Axis, BooleanOp, Contour, ContourId, CurveSegment, CurveSegmentIter, Font, + FontMetadata, FontMetrics, Glyph, GlyphLayer, GlyphName, GuidelineId, LayerId, Location, Point, + PointId, PointType, Source, SourceId, Transform, }; pub use shift_backends::ufo::{UfoReader, UfoWriter}; diff --git a/crates/shift-ir/src/axis.rs b/crates/shift-ir/src/axis.rs index 75cc3d20..82978f72 100644 --- a/crates/shift-ir/src/axis.rs +++ b/crates/shift-ir/src/axis.rs @@ -1,7 +1,10 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use ts_rs::TS; -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../../packages/types/src/generated/")] pub struct Axis { tag: String, name: String, @@ -94,7 +97,8 @@ impl Axis { } } -#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../packages/types/src/generated/")] pub struct Location { values: HashMap, } diff --git a/crates/shift-ir/src/source.rs b/crates/shift-ir/src/source.rs index bbe49a6a..aa593e29 100644 --- a/crates/shift-ir/src/source.rs +++ b/crates/shift-ir/src/source.rs @@ -1,12 +1,17 @@ use crate::axis::Location; use crate::entity::{LayerId, SourceId}; use serde::{Deserialize, Serialize}; +use ts_rs::TS; -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../../packages/types/src/generated/")] pub struct Source { + #[ts(type = "string")] id: SourceId, name: String, location: Location, + #[ts(type = "string")] layer_id: LayerId, filename: Option, } diff --git a/crates/shift-node/index.d.ts b/crates/shift-node/index.d.ts index 28f06265..2be53a65 100644 --- a/crates/shift-node/index.d.ts +++ b/crates/shift-node/index.d.ts @@ -25,6 +25,14 @@ export declare class FontEngine { getGlyphBbox(unicode: number): Array | null getGlyphBboxByName(glyphName: string): Array | null getGlyphCompositeComponents(glyphName: string): string | null + isVariable(): boolean + getAxes(): string + getSources(): string + /** + * Returns a JSON object mapping source IDs to their glyph snapshots, + * including the source location. Used by the TS interpolation engine. + */ + getGlyphMasterSnapshots(glyphName: string): string | null startEditSession(glyphRef: JsGlyphRef): void endEditSession(): void hasEditSession(): boolean diff --git a/crates/shift-node/src/font_engine.rs b/crates/shift-node/src/font_engine.rs index 3368adc6..cc4d7522 100644 --- a/crates/shift-node/src/font_engine.rs +++ b/crates/shift-node/src/font_engine.rs @@ -11,7 +11,7 @@ use shift_core::{ edit_session::EditSession, font_loader::FontLoader, snapshot::{CommandResult, GlyphSnapshot, RenderContourSnapshot}, - AnchorId, BooleanOp, ContourId, Font, FontWriter, Glyph, GlyphLayer, GuidelineId, LayerId, + AnchorId, BooleanOp, ContourId, Font, FontWriter, Glyph, GlyphLayer, GuidelineId, LayerId, Location, NodePositionUpdate, NodeRef, PasteContour, PointId, PointType, UfoWriter, }; use std::collections::HashSet; @@ -517,6 +517,91 @@ impl FontEngine { })) } + // ═══════════════════════════════════════════════════════════ + // VARIABLE FONT QUERIES + // ═══════════════════════════════════════════════════════════ + + #[napi] + pub fn is_variable(&self) -> bool { + self.font.is_variable() + } + + #[napi] + pub fn get_axes(&self) -> String { + to_json(&self.font.axes()) + } + + #[napi] + pub fn get_sources(&self) -> String { + to_json(&self.font.sources()) + } + + /// Returns a JSON object mapping source IDs to their glyph snapshots, + /// including the source location. Used by the TS interpolation engine. + #[napi] + pub fn get_glyph_master_snapshots(&self, glyph_name: String) -> Option { + let glyph = self.font.glyph(&glyph_name)?; + + if !self.font.is_variable() { + return None; + } + + #[derive(serde::Serialize)] + #[serde(rename_all = "camelCase")] + struct MasterSnapshot { + source_id: String, + source_name: String, + location: Location, + snapshot: GlyphSnapshot, + } + + let mut masters: Vec = Vec::new(); + + for source in self.font.sources() { + let layer_id = source.layer_id(); + let layer = match glyph.layer(layer_id) { + Some(l) => l, + None => continue, + }; + + let primary_unicode = glyph.primary_unicode().unwrap_or(0); + + let contours = layer + .contours() + .values() + .map(shift_core::snapshot::ContourSnapshot::from) + .collect(); + + let anchors = layer + .anchors_iter() + .map(shift_core::snapshot::AnchorSnapshot::from) + .collect(); + + let snapshot = GlyphSnapshot { + unicode: primary_unicode, + name: glyph.name().to_string(), + x_advance: layer.width(), + contours, + anchors, + composite_contours: Vec::new(), + active_contour_id: None, + }; + + masters.push(MasterSnapshot { + source_id: source.id().raw().to_string(), + source_name: source.name().to_string(), + location: source.location().clone(), + snapshot, + }); + } + + if masters.is_empty() { + return None; + } + + Some(to_json(&masters)) + } + // ═══════════════════════════════════════════════════════════ // EDIT SESSIONS // ═══════════════════════════════════════════════════════════ diff --git a/packages/types/src/font.ts b/packages/types/src/font.ts index 29735ec6..0ed20719 100644 --- a/packages/types/src/font.ts +++ b/packages/types/src/font.ts @@ -16,6 +16,9 @@ export type { MatchedRule, FontMetrics, FontMetadata, + Axis, + Location, + Source, } from "./generated"; // Domain types (for Editor API) diff --git a/packages/types/src/generated/Axis.ts b/packages/types/src/generated/Axis.ts new file mode 100644 index 00000000..185f21dc --- /dev/null +++ b/packages/types/src/generated/Axis.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Axis = { tag: string, name: string, minimum: number, default: number, maximum: number, hidden: boolean, }; diff --git a/packages/types/src/generated/Location.ts b/packages/types/src/generated/Location.ts new file mode 100644 index 00000000..dbbf8be7 --- /dev/null +++ b/packages/types/src/generated/Location.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Location = { values: { [key in string]?: number }, }; diff --git a/packages/types/src/generated/Source.ts b/packages/types/src/generated/Source.ts new file mode 100644 index 00000000..33e14357 --- /dev/null +++ b/packages/types/src/generated/Source.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Location } from "./Location"; + +export type Source = { id: string, name: string, location: Location, layerId: string, filename: string | null, }; diff --git a/packages/types/src/generated/index.ts b/packages/types/src/generated/index.ts index 80334416..dceeb89f 100644 --- a/packages/types/src/generated/index.ts +++ b/packages/types/src/generated/index.ts @@ -15,3 +15,6 @@ export type { MatchedRule } from "./MatchedRule"; export type { FontMetrics } from "./FontMetrics"; export type { FontMetadata } from "./FontMetadata"; export type { DecomposedTransform } from "./DecomposedTransform"; +export type { Axis } from "./Axis"; +export type { Location } from "./Location"; +export type { Source } from "./Source"; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 4ec58d92..b599e235 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -37,6 +37,9 @@ export type { FontMetadata, FontMetrics, DecomposedTransform, + Axis, + Location, + Source, } from "./font"; // Domain types (for Editor API) diff --git a/scripts/oxlint/shift-plugin.mjs b/scripts/oxlint/shift-plugin.mjs index f2442572..a78307c8 100644 --- a/scripts/oxlint/shift-plugin.mjs +++ b/scripts/oxlint/shift-plugin.mjs @@ -23,6 +23,7 @@ const CONTOURS_ALLOWED = [ "Editor.ts", // coordinator-level structural traversal "clipboard/", // ClipboardContent is not a Glyph, different type "ContourDoubleClick.ts", // finds contour by segment match + "interpolation/", // interpolation produces snapshots from raw contour data ]; function checkParam(context, node) { @@ -60,6 +61,7 @@ const SNAPSHOT_ALLOWED = [ "behaviors/", // tool behaviors capture snapshots for undo via drafts "types/engine.ts", // engine response types "lib/model/", // reactive model uses snapshots for sync + "interpolation/", // interpolation produces snapshots by blending masters ]; function isAllowedFile(filename, allowList) { From 286d730b94915a455576993c857c78f0f283a178 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 12:08:44 +0000 Subject: [PATCH 02/41] Add MutatorSansVariable.glyphs fixture and variable font tests Two-master (Light 100 / Bold 900) .glyphs fixture with weight axis and 3 glyphs (A, I, space). Adds Rust-side tests for axis parsing, source extraction, layer count, and master compatibility. Adds NAPI integration tests for isVariable, getAxes, getSources, and getGlyphMasterSnapshots. https://claude.ai/code/session_01EFbSfzoWykbgJ3mLFpw4wy --- crates/shift-core/tests/font_loading.rs | 88 +++++++++++ .../__test__/font_integration.spec.mjs | 72 +++++++++ fixtures/fonts/MutatorSansVariable.glyphs | 143 ++++++++++++++++++ 3 files changed, 303 insertions(+) create mode 100644 fixtures/fonts/MutatorSansVariable.glyphs diff --git a/crates/shift-core/tests/font_loading.rs b/crates/shift-core/tests/font_loading.rs index 6c773cc2..fd4785e5 100644 --- a/crates/shift-core/tests/font_loading.rs +++ b/crates/shift-core/tests/font_loading.rs @@ -483,3 +483,91 @@ fn test_otf_has_glyph_contours() { "OTF glyph 'A' should have contours" ); } + +// --- Variable font (multi-master .glyphs) tests --- + +fn mutatorsans_variable_glyphs_path() -> PathBuf { + fixtures_path().join("fonts/MutatorSansVariable.glyphs") +} + +#[test] +fn test_variable_glyphs_is_variable() { + let path = mutatorsans_variable_glyphs_path(); + let loader = FontLoader::new(); + let font = loader + .read_font(path.to_str().unwrap()) + .expect("Failed to load variable .glyphs font"); + + assert!(font.is_variable(), "Multi-master font should be variable"); +} + +#[test] +fn test_variable_glyphs_axes() { + let path = mutatorsans_variable_glyphs_path(); + let loader = FontLoader::new(); + let font = loader.read_font(path.to_str().unwrap()).unwrap(); + + let axes = font.axes(); + assert_eq!(axes.len(), 1, "Should have 1 axis"); + assert_eq!(axes[0].tag(), "wght"); + assert_eq!(axes[0].name(), "Weight"); + assert_eq!(axes[0].minimum(), 100.0); + assert_eq!(axes[0].maximum(), 900.0); + assert_eq!(axes[0].default(), 100.0); +} + +#[test] +fn test_variable_glyphs_sources() { + let path = mutatorsans_variable_glyphs_path(); + let loader = FontLoader::new(); + let font = loader.read_font(path.to_str().unwrap()).unwrap(); + + let sources = font.sources(); + assert_eq!(sources.len(), 2, "Should have 2 sources (Light and Bold)"); + + let light = &sources[0]; + assert_eq!(light.location().get("wght"), Some(100.0)); + + let bold = &sources[1]; + assert_eq!(bold.location().get("wght"), Some(900.0)); +} + +#[test] +fn test_variable_glyphs_glyph_has_multiple_layers() { + let path = mutatorsans_variable_glyphs_path(); + let loader = FontLoader::new(); + let font = loader.read_font(path.to_str().unwrap()).unwrap(); + + let glyph_a = font.glyph("A").expect("Glyph 'A' should exist"); + assert_eq!( + glyph_a.layers().len(), + 2, + "Variable glyph 'A' should have 2 layers (one per master)" + ); +} + +#[test] +fn test_variable_glyphs_masters_are_compatible() { + let path = mutatorsans_variable_glyphs_path(); + let loader = FontLoader::new(); + let font = loader.read_font(path.to_str().unwrap()).unwrap(); + + let glyph_a = font.glyph("A").expect("Glyph 'A' should exist"); + let layers: Vec<_> = glyph_a.layers().values().collect(); + assert_eq!(layers.len(), 2); + + // Both layers should have the same number of contours + assert_eq!( + layers[0].contours().len(), + layers[1].contours().len(), + "Masters should have the same number of contours" + ); + + // Both outer contours should have the same number of points + let contour0_points: usize = layers[0].contours().iter().map(|(_, c)| c.points().len()).sum(); + let contour1_points: usize = layers[1].contours().iter().map(|(_, c)| c.points().len()).sum(); + assert_eq!( + contour0_points, contour1_points, + "Masters should have the same total point count" + ); +} diff --git a/crates/shift-node/__test__/font_integration.spec.mjs b/crates/shift-node/__test__/font_integration.spec.mjs index d239cd23..5f618475 100644 --- a/crates/shift-node/__test__/font_integration.spec.mjs +++ b/crates/shift-node/__test__/font_integration.spec.mjs @@ -499,3 +499,75 @@ describe("FontEngine Integration - Extended Round Trip", () => { expect(reloadedTypes).toEqual(originalTypes); }); }); + +// --- Variable font (.glyphs with multiple masters) tests --- + +const MUTATORSANS_VARIABLE = join( + FIXTURES_PATH, + "fonts/MutatorSansVariable.glyphs" +); + +describe("FontEngine Integration - Variable Font (.glyphs)", () => { + it("detects variable font", () => { + const engine = new FontEngine(); + engine.loadFont(MUTATORSANS_VARIABLE); + expect(engine.isVariable()).toBe(true); + }); + + it("returns axes", () => { + const engine = new FontEngine(); + engine.loadFont(MUTATORSANS_VARIABLE); + const axes = JSON.parse(engine.getAxes()); + expect(axes).toHaveLength(1); + expect(axes[0].tag).toBe("wght"); + expect(axes[0].name).toBe("Weight"); + expect(axes[0].minimum).toBe(100); + expect(axes[0].maximum).toBe(900); + expect(axes[0].default).toBe(100); + }); + + it("returns sources", () => { + const engine = new FontEngine(); + engine.loadFont(MUTATORSANS_VARIABLE); + const sources = JSON.parse(engine.getSources()); + expect(sources).toHaveLength(2); + expect(sources[0].location.values.wght).toBe(100); + expect(sources[1].location.values.wght).toBe(900); + }); + + it("returns master snapshots for glyph A", () => { + const engine = new FontEngine(); + engine.loadFont(MUTATORSANS_VARIABLE); + const json = engine.getGlyphMasterSnapshots("A"); + expect(json).not.toBeNull(); + const masters = JSON.parse(json); + expect(masters).toHaveLength(2); + + // Each master snapshot has sourceId, sourceName, location, snapshot + for (const m of masters) { + expect(m).toHaveProperty("sourceId"); + expect(m).toHaveProperty("sourceName"); + expect(m).toHaveProperty("location"); + expect(m).toHaveProperty("snapshot"); + expect(m.snapshot.contours).toHaveLength(2); + } + + // Both masters should have matching point counts per contour + const lightCounts = masters[0].snapshot.contours.map(c => c.points.length); + const boldCounts = masters[1].snapshot.contours.map(c => c.points.length); + expect(lightCounts).toEqual(boldCounts); + }); + + it("non-variable font returns isVariable false", () => { + const engine = new FontEngine(); + engine.loadFont(MUTATORSANS_UFO); + expect(engine.isVariable()).toBe(false); + }); + + it("returns null for non-existent glyph master snapshots", () => { + const engine = new FontEngine(); + engine.loadFont(MUTATORSANS_VARIABLE); + const json = engine.getGlyphMasterSnapshots("nonexistent"); + expect(json).toBeNull(); + }); +}); diff --git a/fixtures/fonts/MutatorSansVariable.glyphs b/fixtures/fonts/MutatorSansVariable.glyphs new file mode 100644 index 00000000..e5b09fe3 --- /dev/null +++ b/fixtures/fonts/MutatorSansVariable.glyphs @@ -0,0 +1,143 @@ +{ +.appVersion = "1225"; +familyName = MutatorSans; +axes = ( +{ +name = Weight; +tag = wght; +} +); +fontMaster = ( +{ +ascender = 800; +capHeight = 700; +descender = -200; +id = master-light; +customName = Light; +weightValue = 100; +xHeight = 500; +}, +{ +ascender = 800; +capHeight = 700; +descender = -200; +id = master-bold; +customName = Bold; +weightValue = 900; +xHeight = 500; +} +); +glyphs = ( +{ +glyphname = A; +unicode = 0041; +layers = ( +{ +layerId = master-light; +width = 500; +paths = ( +{ +closed = 1; +nodes = ( +"0 0 LINE", +"200 700 LINE", +"250 700 LINE", +"450 0 LINE", +"375 0 LINE", +"325 200 LINE", +"125 200 LINE", +"75 0 LINE" +); +}, +{ +closed = 1; +nodes = ( +"150 270 LINE", +"300 270 LINE", +"225 560 LINE" +); +} +); +}, +{ +layerId = master-bold; +width = 600; +paths = ( +{ +closed = 1; +nodes = ( +"0 0 LINE", +"180 700 LINE", +"320 700 LINE", +"500 0 LINE", +"380 0 LINE", +"330 200 LINE", +"170 200 LINE", +"120 0 LINE" +); +}, +{ +closed = 1; +nodes = ( +"200 270 LINE", +"300 270 LINE", +"250 520 LINE" +); +} +); +} +); +}, +{ +glyphname = space; +unicode = 0020; +layers = ( +{ +layerId = master-light; +width = 250; +}, +{ +layerId = master-bold; +width = 300; +} +); +}, +{ +glyphname = I; +unicode = 0049; +layers = ( +{ +layerId = master-light; +width = 200; +paths = ( +{ +closed = 1; +nodes = ( +"50 0 LINE", +"50 700 LINE", +"100 700 LINE", +"100 0 LINE" +); +} +); +}, +{ +layerId = master-bold; +width = 300; +paths = ( +{ +closed = 1; +nodes = ( +"50 0 LINE", +"50 700 LINE", +"200 700 LINE", +"200 0 LINE" +); +} +); +} +); +} +); +unitsPerEm = 1000; +} From 5c640bfe3f720e521e3078f95d22ff6f69e99b45 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 18:28:53 +0100 Subject: [PATCH 03/41] Add .designspace file loading support Use norad's DesignSpaceDocument to parse .designspace XML, resolve UFO source paths relative to the designspace directory, and load each source into a separate layer with axis/source metadata. Wire through FontLoader, Electron file dialog, and path validation. Also fix pre-commit cargo-test hook to exclude shift-node (NAPI symbols unavailable outside Node.js). Co-Authored-By: Claude Opus 4.6 (1M context) --- ROADMAP.md | 2 +- .../desktop/src/main/managers/AppLifecycle.ts | 9 +- apps/desktop/src/main/managers/MenuManager.ts | 7 +- .../desktop/src/main/managers/openFontPath.ts | 9 +- .../src/renderer/src/bridge/NativeBridge.ts | 6 +- crates/shift-backends/src/designspace/mod.rs | 3 + .../shift-backends/src/designspace/reader.rs | 162 ++++++++++++++++++ crates/shift-backends/src/lib.rs | 1 + crates/shift-core/src/font_loader.rs | 27 +++ crates/shift-core/tests/font_loading.rs | 90 +++++++++- .../__test__/font_integration.spec.mjs | 55 +++++- .../MutatorSans-Bold.ufo/fontinfo.plist | 20 +++ .../MutatorSans-Bold.ufo/glyphs/A_.glif | 22 +++ .../glyphs/contents.plist | 10 ++ .../MutatorSans-Bold.ufo/glyphs/space.glif | 5 + .../MutatorSans-Bold.ufo/layercontents.plist | 10 ++ .../MutatorSans-Bold.ufo/metainfo.plist | 10 ++ .../MutatorSans-Light.ufo/fontinfo.plist | 20 +++ .../MutatorSans-Light.ufo/glyphs/A_.glif | 22 +++ .../glyphs/contents.plist | 10 ++ .../MutatorSans-Light.ufo/glyphs/space.glif | 5 + .../MutatorSans-Light.ufo/layercontents.plist | 10 ++ .../MutatorSans-Light.ufo/metainfo.plist | 10 ++ .../MutatorSans.designspace | 18 ++ 24 files changed, 527 insertions(+), 16 deletions(-) create mode 100644 crates/shift-backends/src/designspace/mod.rs create mode 100644 crates/shift-backends/src/designspace/reader.rs create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/fontinfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/A_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/contents.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/space.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/layercontents.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/metainfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/fontinfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/glyphs/A_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/glyphs/contents.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/glyphs/space.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/layercontents.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/metainfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSans.designspace diff --git a/ROADMAP.md b/ROADMAP.md index b44251eb..c439bc36 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -361,7 +361,7 @@ **Designspace Support** -- [ ] Load `.designspace` files +- [x] Load `.designspace` files - [x] Parse axis definitions (wght, wdth, ital, custom) - [ ] Named instances diff --git a/apps/desktop/src/main/managers/AppLifecycle.ts b/apps/desktop/src/main/managers/AppLifecycle.ts index 781c31fa..76fdd021 100644 --- a/apps/desktop/src/main/managers/AppLifecycle.ts +++ b/apps/desktop/src/main/managers/AppLifecycle.ts @@ -207,8 +207,13 @@ export class AppLifecycle { ipc.handle(ipcMain, "dialog:openFont", async () => { const result = await dialog.showOpenDialog({ - properties: ["openFile"], - filters: [{ name: "Fonts", extensions: ["ttf", "otf", "ufo", "glyphs", "glyphspackage"] }], + properties: ["openFile", "openDirectory"], + filters: [ + { + name: "Fonts", + extensions: ["ttf", "otf", "ufo", "glyphs", "glyphspackage", "designspace"], + }, + ], }); if (!result.canceled && result.filePaths[0]) { return result.filePaths[0]; diff --git a/apps/desktop/src/main/managers/MenuManager.ts b/apps/desktop/src/main/managers/MenuManager.ts index f6173258..100493be 100644 --- a/apps/desktop/src/main/managers/MenuManager.ts +++ b/apps/desktop/src/main/managers/MenuManager.ts @@ -118,9 +118,12 @@ export class MenuManager { accelerator: "CmdOrCtrl+O", click: async () => { const result = await dialog.showOpenDialog({ - properties: ["openFile"], + properties: ["openFile", "openDirectory"], filters: [ - { name: "Fonts", extensions: ["ttf", "otf", "ufo", "glyphs", "glyphspackage"] }, + { + name: "Fonts", + extensions: ["ttf", "otf", "ufo", "glyphs", "glyphspackage", "designspace"], + }, ], }); if (!result.canceled && result.filePaths[0]) { diff --git a/apps/desktop/src/main/managers/openFontPath.ts b/apps/desktop/src/main/managers/openFontPath.ts index 515b521b..ca318c03 100644 --- a/apps/desktop/src/main/managers/openFontPath.ts +++ b/apps/desktop/src/main/managers/openFontPath.ts @@ -1,6 +1,13 @@ import path from "node:path"; -const SUPPORTED_FONT_EXTENSIONS = new Set([".ufo", ".ttf", ".otf", ".glyphs", ".glyphspackage"]); +const SUPPORTED_FONT_EXTENSIONS = new Set([ + ".ufo", + ".ttf", + ".otf", + ".glyphs", + ".glyphspackage", + ".designspace", +]); export function isSupportedFontPath(filePath: string): boolean { const ext = path.extname(filePath).toLowerCase(); diff --git a/apps/desktop/src/renderer/src/bridge/NativeBridge.ts b/apps/desktop/src/renderer/src/bridge/NativeBridge.ts index c5aec4b1..d6d8cde2 100644 --- a/apps/desktop/src/renderer/src/bridge/NativeBridge.ts +++ b/apps/desktop/src/renderer/src/bridge/NativeBridge.ts @@ -141,20 +141,22 @@ export class NativeBridge { return JSON.parse(payload) as CompositeComponentsPayload; } - // ── Variable Font Queries ── - + /** @knipclassignore — used by VariationPanel component */ isVariable(): boolean { return this.#raw.isVariable(); } + /** @knipclassignore — used by VariationPanel component */ getAxes(): Axis[] { return JSON.parse(this.#raw.getAxes()) as Axis[]; } + /** @knipclassignore — used by VariationPanel component */ getSources(): Source[] { return JSON.parse(this.#raw.getSources()) as Source[]; } + /** @knipclassignore — used by VariationPanel component */ getGlyphMasterSnapshots(glyphName: string): MasterSnapshot[] | null { const json = this.#raw.getGlyphMasterSnapshots(glyphName); if (!json) return null; diff --git a/crates/shift-backends/src/designspace/mod.rs b/crates/shift-backends/src/designspace/mod.rs new file mode 100644 index 00000000..df76ba27 --- /dev/null +++ b/crates/shift-backends/src/designspace/mod.rs @@ -0,0 +1,3 @@ +mod reader; + +pub use reader::DesignspaceReader; diff --git a/crates/shift-backends/src/designspace/reader.rs b/crates/shift-backends/src/designspace/reader.rs new file mode 100644 index 00000000..7904ab3c --- /dev/null +++ b/crates/shift-backends/src/designspace/reader.rs @@ -0,0 +1,162 @@ +use crate::traits::FontReader; +use crate::ufo::UfoReader; +use norad::designspace::DesignSpaceDocument; +use shift_ir::{Axis, Font, Layer, Location, Source}; +use std::path::Path; + +pub struct DesignspaceReader; + +impl DesignspaceReader { + pub fn new() -> Self { + Self + } +} + +impl Default for DesignspaceReader { + fn default() -> Self { + Self::new() + } +} + +impl FontReader for DesignspaceReader { + fn load(&self, path: &str) -> Result { + let ds_path = Path::new(path); + let ds_dir = ds_path + .parent() + .ok_or_else(|| format!("Cannot determine directory of '{path}'"))?; + + let doc = DesignSpaceDocument::load(ds_path) + .map_err(|e| format!("Failed to load designspace '{path}': {e}"))?; + + if doc.sources.is_empty() { + return Err("Designspace has no sources".to_string()); + } + + // Find the default source — the one whose location matches axis defaults. + let default_idx = find_default_source_index(&doc); + + // Load the default source first to establish the base font. + let default_ds_source = &doc.sources[default_idx]; + let default_ufo_path = ds_dir.join(&default_ds_source.filename); + let default_ufo_str = default_ufo_path + .to_str() + .ok_or_else(|| "Invalid UTF-8 in default UFO path".to_string())?; + + let ufo_reader = UfoReader::new(); + let mut font = ufo_reader.load(default_ufo_str)?; + + // Override metadata from the designspace source if available. + if let Some(ref family) = default_ds_source.familyname { + font.metadata_mut().family_name = Some(family.clone()); + } + + // Add axes. + for ds_axis in &doc.axes { + let minimum = ds_axis.minimum.unwrap_or(ds_axis.default) as f64; + let maximum = ds_axis.maximum.unwrap_or(ds_axis.default) as f64; + let mut axis = Axis::new( + ds_axis.tag.clone(), + ds_axis.name.clone(), + minimum, + ds_axis.default as f64, + maximum, + ); + axis.set_hidden(ds_axis.hidden); + font.add_axis(axis); + } + + // Register the default source. + let default_layer_id = font.default_layer_id(); + let default_location = location_from_dimensions(&default_ds_source.location, &doc); + let default_name = source_name(default_ds_source, default_idx); + font.add_source(Source::with_filename( + default_name, + default_location, + default_layer_id, + default_ds_source.filename.clone(), + )); + + // Load each non-default source as a new layer. + for (idx, ds_source) in doc.sources.iter().enumerate() { + if idx == default_idx { + continue; + } + + let ufo_path = ds_dir.join(&ds_source.filename); + let ufo_str = ufo_path + .to_str() + .ok_or_else(|| format!("Invalid UTF-8 in UFO path: {:?}", ufo_path))?; + + let source_font = ufo_reader.load(ufo_str)?; + let source_default_layer = source_font.default_layer_id(); + + let name = source_name(ds_source, idx); + let layer = Layer::new(name.clone()); + let layer_id = font.add_layer(layer); + + // Copy glyphs from this source's default layer into the new layer. + for (glyph_name, source_glyph) in source_font.glyphs() { + if let Some(source_layer) = source_glyph.layer(source_default_layer) { + if let Some(existing_glyph) = font.glyph_mut(glyph_name) { + existing_glyph.set_layer(layer_id, source_layer.clone()); + } + } + } + + let location = location_from_dimensions(&ds_source.location, &doc); + font.add_source(Source::with_filename( + name, + location, + layer_id, + ds_source.filename.clone(), + )); + } + + Ok(font) + } +} + +fn source_name(source: &norad::designspace::Source, index: usize) -> String { + source + .name + .clone() + .or_else(|| source.stylename.clone()) + .unwrap_or_else(|| format!("Source {index}")) +} + +fn location_from_dimensions( + dimensions: &[norad::designspace::Dimension], + doc: &DesignSpaceDocument, +) -> Location { + let mut location = Location::new(); + for dim in dimensions { + // Dimension uses axis name; we need the axis tag. + let value = dim.xvalue.unwrap_or(0.0) as f64; + if let Some(axis) = doc.axes.iter().find(|a| a.name == dim.name) { + location.set(axis.tag.clone(), value); + } + } + location +} + +fn find_default_source_index(doc: &DesignSpaceDocument) -> usize { + // The default source is the one whose location matches axis defaults. + for (idx, source) in doc.sources.iter().enumerate() { + let is_default = doc.axes.iter().all(|axis| { + source + .location + .iter() + .find(|d| d.name == axis.name) + .map(|d| { + let val = d.xvalue.unwrap_or(0.0); + (val - axis.default).abs() < 0.001 + }) + .unwrap_or(false) + }); + if is_default { + return idx; + } + } + // Fall back to first source. + 0 +} diff --git a/crates/shift-backends/src/lib.rs b/crates/shift-backends/src/lib.rs index f0019a39..92c9d740 100644 --- a/crates/shift-backends/src/lib.rs +++ b/crates/shift-backends/src/lib.rs @@ -1,3 +1,4 @@ +pub mod designspace; pub mod glyphs; mod traits; pub mod ufo; diff --git a/crates/shift-core/src/font_loader.rs b/crates/shift-core/src/font_loader.rs index d0e88a88..f618efc3 100644 --- a/crates/shift-core/src/font_loader.rs +++ b/crates/shift-core/src/font_loader.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::path::Path; use crate::binary::BytesFontAdaptor; +use shift_backends::designspace::DesignspaceReader; use shift_backends::glyphs::GlyphsReader; use shift_backends::ufo::UfoReader; use shift_backends::FontReader; @@ -11,6 +12,7 @@ use shift_ir::Font; pub enum FontFormat { Ufo, Glyphs, + Designspace, Ttf, Otf, } @@ -22,6 +24,7 @@ pub trait FontAdaptor { struct UfoFontAdaptor; struct GlyphsFontAdaptor; +struct DesignspaceFontAdaptor; impl FontAdaptor for UfoFontAdaptor { fn read_font(&self, path: &str) -> Result { @@ -45,6 +48,16 @@ impl FontAdaptor for GlyphsFontAdaptor { } } +impl FontAdaptor for DesignspaceFontAdaptor { + fn read_font(&self, path: &str) -> Result { + DesignspaceReader::new().load(path) + } + + fn write_font(&self, _font: &Font, _path: &str) -> Result<(), String> { + Err("Designspace writing is not supported; save as .ufo instead".to_string()) + } +} + pub struct FontLoader { adaptors: HashMap>, } @@ -60,6 +73,7 @@ fn format_from_extension(ext: &str) -> Result { "ufo" => Ok(FontFormat::Ufo), "glyphs" => Ok(FontFormat::Glyphs), "glyphspackage" => Ok(FontFormat::Glyphs), + "designspace" => Ok(FontFormat::Designspace), "ttf" => Ok(FontFormat::Ttf), "otf" => Ok(FontFormat::Otf), _ => Err(format!("Unsupported font format: {ext}")), @@ -78,6 +92,7 @@ impl FontLoader { let mut adaptors: HashMap> = HashMap::new(); adaptors.insert(FontFormat::Ufo, Box::new(UfoFontAdaptor)); adaptors.insert(FontFormat::Glyphs, Box::new(GlyphsFontAdaptor)); + adaptors.insert(FontFormat::Designspace, Box::new(DesignspaceFontAdaptor)); adaptors.insert(FontFormat::Ttf, Box::new(BytesFontAdaptor)); adaptors.insert(FontFormat::Otf, Box::new(BytesFontAdaptor)); @@ -130,6 +145,14 @@ mod tests { )); } + #[test] + fn supports_designspace_extension() { + assert!(matches!( + format_from_extension("designspace"), + Ok(FontFormat::Designspace) + )); + } + #[test] fn extension_matching_is_case_insensitive() { assert!(matches!(format_from_extension("UFO"), Ok(FontFormat::Ufo))); @@ -138,5 +161,9 @@ mod tests { Ok(FontFormat::Glyphs) )); assert!(matches!(format_from_extension("OTF"), Ok(FontFormat::Otf))); + assert!(matches!( + format_from_extension("DESIGNSPACE"), + Ok(FontFormat::Designspace) + )); } } diff --git a/crates/shift-core/tests/font_loading.rs b/crates/shift-core/tests/font_loading.rs index fd4785e5..1c49f3b5 100644 --- a/crates/shift-core/tests/font_loading.rs +++ b/crates/shift-core/tests/font_loading.rs @@ -564,10 +564,96 @@ fn test_variable_glyphs_masters_are_compatible() { ); // Both outer contours should have the same number of points - let contour0_points: usize = layers[0].contours().iter().map(|(_, c)| c.points().len()).sum(); - let contour1_points: usize = layers[1].contours().iter().map(|(_, c)| c.points().len()).sum(); + let contour0_points: usize = layers[0] + .contours() + .values() + .map(|c| c.points().len()) + .sum(); + let contour1_points: usize = layers[1] + .contours() + .values() + .map(|c| c.points().len()) + .sum(); assert_eq!( contour0_points, contour1_points, "Masters should have the same total point count" ); } + +// --- Designspace (.designspace) tests --- + +fn mutatorsans_designspace_path() -> PathBuf { + fixtures_path().join("fonts/mutatorsans-variable/MutatorSans.designspace") +} + +#[test] +fn test_designspace_loads() { + let path = mutatorsans_designspace_path(); + let loader = FontLoader::new(); + let font = loader + .read_font(path.to_str().unwrap()) + .expect("Failed to load designspace"); + + assert!(font.is_variable(), "Designspace font should be variable"); + assert_eq!(font.glyph_count(), 2, "Should have 2 glyphs (A, space)"); +} + +#[test] +fn test_designspace_axes() { + let path = mutatorsans_designspace_path(); + let loader = FontLoader::new(); + let font = loader.read_font(path.to_str().unwrap()).unwrap(); + + let axes = font.axes(); + assert_eq!(axes.len(), 1); + assert_eq!(axes[0].tag(), "wght"); + assert_eq!(axes[0].name(), "Weight"); + assert_eq!(axes[0].minimum(), 100.0); + assert_eq!(axes[0].maximum(), 900.0); + assert_eq!(axes[0].default(), 100.0); +} + +#[test] +fn test_designspace_sources() { + let path = mutatorsans_designspace_path(); + let loader = FontLoader::new(); + let font = loader.read_font(path.to_str().unwrap()).unwrap(); + + let sources = font.sources(); + assert_eq!(sources.len(), 2, "Should have 2 sources"); + + let light = &sources[0]; + assert_eq!(light.location().get("wght"), Some(100.0)); + assert!(light.filename().is_some()); + + let bold = &sources[1]; + assert_eq!(bold.location().get("wght"), Some(900.0)); +} + +#[test] +fn test_designspace_glyph_has_two_layers() { + let path = mutatorsans_designspace_path(); + let loader = FontLoader::new(); + let font = loader.read_font(path.to_str().unwrap()).unwrap(); + + let glyph_a = font.glyph("A").expect("Glyph 'A' should exist"); + assert_eq!( + glyph_a.layers().len(), + 2, + "Glyph A should have 2 layers (one per master)" + ); +} + +#[test] +fn test_designspace_metadata_from_default_source() { + let path = mutatorsans_designspace_path(); + let loader = FontLoader::new(); + let font = loader.read_font(path.to_str().unwrap()).unwrap(); + + let metadata = font.metadata(); + assert_eq!( + metadata.family_name.as_deref(), + Some("MutatorSans"), + "Family name should come from designspace source" + ); +} diff --git a/crates/shift-node/__test__/font_integration.spec.mjs b/crates/shift-node/__test__/font_integration.spec.mjs index 5f618475..281733d0 100644 --- a/crates/shift-node/__test__/font_integration.spec.mjs +++ b/crates/shift-node/__test__/font_integration.spec.mjs @@ -502,10 +502,7 @@ describe("FontEngine Integration - Extended Round Trip", () => { // --- Variable font (.glyphs with multiple masters) tests --- -const MUTATORSANS_VARIABLE = join( - FIXTURES_PATH, - "fonts/MutatorSansVariable.glyphs" -); +const MUTATORSANS_VARIABLE = join(FIXTURES_PATH, "fonts/MutatorSansVariable.glyphs"); describe("FontEngine Integration - Variable Font (.glyphs)", () => { it("detects variable font", () => { @@ -553,8 +550,8 @@ describe("FontEngine Integration - Variable Font (.glyphs)", () => { } // Both masters should have matching point counts per contour - const lightCounts = masters[0].snapshot.contours.map(c => c.points.length); - const boldCounts = masters[1].snapshot.contours.map(c => c.points.length); + const lightCounts = masters[0].snapshot.contours.map((c) => c.points.length); + const boldCounts = masters[1].snapshot.contours.map((c) => c.points.length); expect(lightCounts).toEqual(boldCounts); }); @@ -571,3 +568,49 @@ describe("FontEngine Integration - Variable Font (.glyphs)", () => { expect(json).toBeNull(); }); }); + +// --- Designspace (.designspace) tests --- + +const MUTATORSANS_DESIGNSPACE = join( + FIXTURES_PATH, + "fonts/mutatorsans-variable/MutatorSans.designspace", +); + +describe("FontEngine Integration - Designspace", () => { + it("loads designspace and detects variable font", () => { + const engine = new FontEngine(); + engine.loadFont(MUTATORSANS_DESIGNSPACE); + expect(engine.isVariable()).toBe(true); + expect(engine.getGlyphCount()).toBe(2); + }); + + it("returns axes from designspace", () => { + const engine = new FontEngine(); + engine.loadFont(MUTATORSANS_DESIGNSPACE); + const axes = JSON.parse(engine.getAxes()); + expect(axes).toHaveLength(1); + expect(axes[0].tag).toBe("wght"); + expect(axes[0].minimum).toBe(100); + expect(axes[0].maximum).toBe(900); + }); + + it("returns sources from designspace", () => { + const engine = new FontEngine(); + engine.loadFont(MUTATORSANS_DESIGNSPACE); + const sources = JSON.parse(engine.getSources()); + expect(sources).toHaveLength(2); + expect(sources[0].location.values.wght).toBe(100); + expect(sources[1].location.values.wght).toBe(900); + }); + + it("returns master snapshots for glyph A", () => { + const engine = new FontEngine(); + engine.loadFont(MUTATORSANS_DESIGNSPACE); + const json = engine.getGlyphMasterSnapshots("A"); + expect(json).not.toBeNull(); + const masters = JSON.parse(json); + expect(masters).toHaveLength(2); + expect(masters[0].snapshot.contours).toHaveLength(2); + expect(masters[1].snapshot.contours).toHaveLength(2); + }); +}); diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/fontinfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/fontinfo.plist new file mode 100644 index 00000000..676cd1ba --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/fontinfo.plist @@ -0,0 +1,20 @@ + + + + + familyName + MutatorSans + styleName + Bold + unitsPerEm + 1000 + ascender + 800 + descender + -200 + capHeight + 700 + xHeight + 500 + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/A_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/A_.glif new file mode 100644 index 00000000..f6af17f2 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/A_.glif @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/contents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/contents.plist new file mode 100644 index 00000000..05f6343f --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/contents.plist @@ -0,0 +1,10 @@ + + + + + A + A_.glif + space + space.glif + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/space.glif b/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/space.glif new file mode 100644 index 00000000..60585b39 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/space.glif @@ -0,0 +1,5 @@ + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/layercontents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/layercontents.plist new file mode 100644 index 00000000..cf95d357 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/layercontents.plist @@ -0,0 +1,10 @@ + + + + + + public.default + glyphs + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/metainfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/metainfo.plist new file mode 100644 index 00000000..632695b5 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/metainfo.plist @@ -0,0 +1,10 @@ + + + + + creator + org.robofab.ufoLib + formatVersion + 3 + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/fontinfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/fontinfo.plist new file mode 100644 index 00000000..8bed42c0 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/fontinfo.plist @@ -0,0 +1,20 @@ + + + + + familyName + MutatorSans + styleName + Light + unitsPerEm + 1000 + ascender + 800 + descender + -200 + capHeight + 700 + xHeight + 500 + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/glyphs/A_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/glyphs/A_.glif new file mode 100644 index 00000000..847a9368 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/glyphs/A_.glif @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/glyphs/contents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/glyphs/contents.plist new file mode 100644 index 00000000..05f6343f --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/glyphs/contents.plist @@ -0,0 +1,10 @@ + + + + + A + A_.glif + space + space.glif + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/glyphs/space.glif b/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/glyphs/space.glif new file mode 100644 index 00000000..f5681cd2 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/glyphs/space.glif @@ -0,0 +1,5 @@ + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/layercontents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/layercontents.plist new file mode 100644 index 00000000..cf95d357 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/layercontents.plist @@ -0,0 +1,10 @@ + + + + + + public.default + glyphs + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/metainfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/metainfo.plist new file mode 100644 index 00000000..632695b5 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/metainfo.plist @@ -0,0 +1,10 @@ + + + + + creator + org.robofab.ufoLib + formatVersion + 3 + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans.designspace b/fixtures/fonts/mutatorsans-variable/MutatorSans.designspace new file mode 100644 index 00000000..ac354ad8 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSans.designspace @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + From dc94be92200356fa40d6eb4d8fd93d0cdc6d93d7 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 18:42:42 +0100 Subject: [PATCH 04/41] Replace homebrew fixtures with real MutatorSans designspace Clone LettError/mutatorSans with 4 masters (LightCondensed, BoldCondensed, LightWide, BoldWide) + 3 support layer sources across 2 axes (wdth, wght). Handle support layer sources in designspace reader by resolving named layers within UFOs. Fix UfoReader default layer detection to use norad's default_layer() instead of hardcoded "public.default" name check. Fix round_trip test to use HashMap key instead of Layer::id() for lookups. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../shift-backends/src/designspace/reader.rs | 57 +- crates/shift-backends/src/ufo/reader.rs | 3 +- crates/shift-core/tests/font_loading.rs | 49 +- crates/shift-core/tests/round_trip.rs | 7 +- .../__test__/font_integration.spec.mjs | 25 +- fixtures/fonts/mutatorsans-variable/LICENSE | 21 + .../MutatorSans-Bold.ufo/fontinfo.plist | 20 - .../MutatorSans-Bold.ufo/glyphs/A_.glif | 22 - .../glyphs/contents.plist | 10 - .../MutatorSans-Bold.ufo/layercontents.plist | 10 - .../MutatorSans-Bold.ufo/metainfo.plist | 10 - .../MutatorSans-Light.ufo/fontinfo.plist | 20 - .../MutatorSans-Light.ufo/glyphs/A_.glif | 22 - .../MutatorSans-Light.ufo/layercontents.plist | 10 - .../MutatorSans-Light.ufo/metainfo.plist | 10 - .../MutatorSans.designspace | 319 ++++++++- .../MutatorSansBoldCondensed.ufo/features.fea | 3 + .../fontinfo.plist | 68 ++ .../glyphs.background/S_.closed.glif | 52 ++ .../glyphs.background/contents.plist | 8 + .../glyphs.background/layerinfo.plist | 8 + .../glyphs/A_.glif | 43 ++ .../glyphs/A_acute.glif | 15 + .../glyphs/A_dieresis.glif | 15 + .../glyphs/B_.glif | 53 ++ .../glyphs/C_.glif | 51 ++ .../glyphs/D_.glif | 33 + .../glyphs/E_.glif | 44 ++ .../glyphs/F_.glif | 25 + .../glyphs/G_.glif | 48 ++ .../glyphs/H_.glif | 25 + .../glyphs/I_.glif | 25 + .../glyphs/I_.narrow.glif | 24 + .../glyphs/I_J_.glif | 33 + .../glyphs/J_.glif | 27 + .../glyphs/J_.narrow.glif | 24 + .../glyphs/K_.glif | 28 + .../glyphs/L_.glif | 19 + .../glyphs/M_.glif | 29 + .../glyphs/N_.glif | 25 + .../glyphs/O_.glif | 39 ++ .../glyphs/P_.glif | 33 + .../glyphs/Q_.glif | 14 + .../glyphs/R_.glif | 54 ++ .../glyphs/S_.closed.glif | 66 ++ .../glyphs/S_.glif | 65 ++ .../glyphs/T_.glif | 19 + .../glyphs/U_.glif | 27 + .../glyphs/V_.glif | 25 + .../glyphs/W_.glif | 25 + .../glyphs/X_.glif | 25 + .../glyphs/Y_.glif | 25 + .../glyphs/Z_.glif | 25 + .../glyphs/_notdef.glif | 42 ++ .../glyphs/acute.glif | 13 + .../glyphs/arrowdown.glif | 17 + .../glyphs/arrowleft.glif | 17 + .../glyphs/arrowright.glif | 17 + .../glyphs/arrowup.glif | 17 + .../glyphs/b.glif | 7 + .../glyphs/c.glif | 7 + .../glyphs/colon.glif | 9 + .../glyphs/comma.glif | 23 + .../glyphs/contents.plist | 104 +++ .../glyphs/d.glif | 7 + .../glyphs/dieresis.glif | 15 + .../glyphs/dot.glif | 13 + .../glyphs/layerinfo.plist | 13 + .../glyphs/period.glif | 19 + .../glyphs/quotedblbase.glif | 21 + .../glyphs/quotedblleft.glif | 9 + .../glyphs/quotedblright.glif | 9 + .../glyphs/quotesinglbase.glif | 20 + .../glyphs/semicolon.glif | 9 + .../glyphs/space.glif | 2 + .../MutatorSansBoldCondensed.ufo/groups.plist | 20 + .../kerning.plist | 210 ++++++ .../layercontents.plist | 14 + .../MutatorSansBoldCondensed.ufo/lib.plist | 229 +++++++ .../metainfo.plist | 10 + .../MutatorSansBoldWide.ufo/features.fea | 3 + .../MutatorSansBoldWide.ufo/fontinfo.plist | 68 ++ .../glyphs.background/S_.closed.glif | 52 ++ .../glyphs.background/S_.glif | 53 ++ .../glyphs.background/contents.plist | 10 + .../glyphs.background/layerinfo.plist | 8 + .../glyphs.crayon/S_.glif | 8 + .../glyphs.crayon/contents.plist | 8 + .../glyphs.crayon/layerinfo.plist | 8 + .../MutatorSansBoldWide.ufo/glyphs/A_.glif | 31 + .../glyphs/A_acute.glif | 15 + .../glyphs/A_dieresis.glif | 15 + .../MutatorSansBoldWide.ufo/glyphs/B_.glif | 61 ++ .../MutatorSansBoldWide.ufo/glyphs/C_.glif | 39 ++ .../MutatorSansBoldWide.ufo/glyphs/D_.glif | 33 + .../MutatorSansBoldWide.ufo/glyphs/E_.glif | 32 + .../MutatorSansBoldWide.ufo/glyphs/F_.glif | 25 + .../MutatorSansBoldWide.ufo/glyphs/G_.glif | 48 ++ .../MutatorSansBoldWide.ufo/glyphs/H_.glif | 25 + .../MutatorSansBoldWide.ufo/glyphs/I_.glif | 25 + .../glyphs/I_.narrow.glif | 24 + .../MutatorSansBoldWide.ufo/glyphs/I_J_.glif | 32 + .../MutatorSansBoldWide.ufo/glyphs/J_.glif | 27 + .../glyphs/J_.narrow.glif | 24 + .../MutatorSansBoldWide.ufo/glyphs/K_.glif | 28 + .../MutatorSansBoldWide.ufo/glyphs/L_.glif | 19 + .../MutatorSansBoldWide.ufo/glyphs/M_.glif | 29 + .../MutatorSansBoldWide.ufo/glyphs/N_.glif | 25 + .../MutatorSansBoldWide.ufo/glyphs/O_.glif | 39 ++ .../MutatorSansBoldWide.ufo/glyphs/P_.glif | 33 + .../MutatorSansBoldWide.ufo/glyphs/Q_.glif | 13 + .../MutatorSansBoldWide.ufo/glyphs/R_.glif | 42 ++ .../glyphs/S_.closed.glif | 64 ++ .../MutatorSansBoldWide.ufo/glyphs/S_.glif | 65 ++ .../MutatorSansBoldWide.ufo/glyphs/T_.glif | 19 + .../MutatorSansBoldWide.ufo/glyphs/U_.glif | 27 + .../MutatorSansBoldWide.ufo/glyphs/V_.glif | 25 + .../MutatorSansBoldWide.ufo/glyphs/W_.glif | 25 + .../MutatorSansBoldWide.ufo/glyphs/X_.glif | 25 + .../MutatorSansBoldWide.ufo/glyphs/Y_.glif | 25 + .../MutatorSansBoldWide.ufo/glyphs/Z_.glif | 25 + .../glyphs/_notdef.glif | 42 ++ .../MutatorSansBoldWide.ufo/glyphs/acute.glif | 13 + .../glyphs/arrowdown.glif | 17 + .../glyphs/arrowleft.glif | 17 + .../glyphs/arrowright.glif | 17 + .../glyphs/arrowup.glif | 17 + .../MutatorSansBoldWide.ufo/glyphs/colon.glif | 9 + .../MutatorSansBoldWide.ufo/glyphs/comma.glif | 17 + .../glyphs/contents.plist | 104 +++ .../glyphs/dieresis.glif | 15 + .../MutatorSansBoldWide.ufo/glyphs/dot.glif | 13 + .../glyphs/layerinfo.plist | 13 + .../glyphs/period.glif | 13 + .../glyphs/quotedblbase.glif | 9 + .../glyphs/quotedblleft.glif | 9 + .../glyphs/quotedblright.glif | 9 + .../glyphs/quotesinglbase.glif | 8 + .../glyphs/semicolon.glif | 9 + .../glyphs/space.glif | 4 +- .../MutatorSansBoldWide.ufo/groups.plist | 20 + .../MutatorSansBoldWide.ufo/images/image.png | Bin 0 -> 101536 bytes .../kerning.plist} | 9 +- .../layercontents.plist | 14 + .../MutatorSansBoldWide.ufo/lib.plist | 355 ++++++++++ .../MutatorSansBoldWide.ufo/metainfo.plist | 10 + .../features.fea | 4 + .../fontinfo.plist | 67 ++ .../glyphs.background/S_.closed.glif | 98 +++ .../glyphs.background/S_.glif | 75 ++ .../glyphs.background/contents.plist | 10 + .../glyphs.background/layerinfo.plist | 8 + .../glyphs.support.S_.middle/S_.closed.glif | 66 ++ .../glyphs.support.S_.middle/contents.plist | 8 + .../glyphs.support.S_.middle/layerinfo.plist | 8 + .../glyphs.support.S_.wide/S_.closed.glif | 66 ++ .../glyphs.support.S_.wide/S_.glif | 66 ++ .../glyphs.support.S_.wide/contents.plist | 10 + .../glyphs.support.S_.wide/layerinfo.plist | 8 + .../glyphs.support.crossbar/B_.glif | 63 ++ .../glyphs.support.crossbar/E_.glif | 46 ++ .../glyphs.support.crossbar/F_.glif | 25 + .../glyphs.support.crossbar/G_.glif | 62 ++ .../glyphs.support.crossbar/contents.plist | 14 + .../glyphs.support.crossbar/layerinfo.plist | 8 + .../glyphs.support/A_.glif | 31 + .../glyphs.support/S_.glif | 77 +++ .../glyphs.support/W_.glif | 25 + .../glyphs.support/contents.plist | 12 + .../glyphs.support/layerinfo.plist | 13 + .../glyphs/A_.glif | 31 + .../glyphs/A_acute.glif | 15 + .../glyphs/A_dieresis.glif | 15 + .../glyphs/B_.glif | 49 ++ .../glyphs/C_.glif | 39 ++ .../glyphs/D_.glif | 33 + .../glyphs/E_.glif | 32 + .../glyphs/F_.glif | 25 + .../glyphs/G_.glif | 48 ++ .../glyphs/H_.glif | 25 + .../glyphs/I_.glif | 25 + .../glyphs/I_.narrow.glif | 24 + .../glyphs/I_J_.glif | 32 + .../glyphs/J_.glif | 27 + .../glyphs/J_.narrow.glif | 24 + .../glyphs/K_.glif | 28 + .../glyphs/L_.glif | 19 + .../glyphs/M_.glif | 29 + .../glyphs/N_.glif | 25 + .../glyphs/O_.glif | 39 ++ .../glyphs/P_.glif | 33 + .../glyphs/Q_.glif | 14 + .../glyphs/R_.glif | 42 ++ .../glyphs/S_.closed.glif | 52 ++ .../glyphs/S_.glif | 65 ++ .../glyphs/T_.glif | 18 + .../glyphs/U_.glif | 26 + .../glyphs/V_.glif | 25 + .../glyphs/W_.glif | 25 + .../glyphs/X_.glif | 24 + .../glyphs/Y_.glif | 36 + .../glyphs/Z_.glif | 24 + .../glyphs/_notdef.glif | 42 ++ .../glyphs/acute.glif | 13 + .../glyphs/arrowdown.glif | 24 + .../glyphs/arrowleft.glif | 25 + .../glyphs/arrowright.glif | 24 + .../glyphs/arrowup.glif | 24 + .../glyphs/b.glif | 7 + .../glyphs/c.glif | 7 + .../glyphs/colon.glif | 9 + .../glyphs/comma.glif | 17 + .../glyphs/contents.plist | 104 +++ .../glyphs/d.glif | 7 + .../glyphs/dieresis.glif | 15 + .../glyphs/dot.glif | 13 + .../glyphs/layerinfo.plist | 13 + .../glyphs/period.glif | 13 + .../glyphs/quotedblbase.glif | 9 + .../glyphs/quotedblleft.glif | 9 + .../glyphs/quotedblright.glif | 9 + .../glyphs/quotesinglbase.glif | 8 + .../glyphs/semicolon.glif | 9 + .../glyphs/space.glif | 7 + .../groups.plist | 20 + .../images/image | Bin 0 -> 33538 bytes .../images/image000000000000001 | Bin 0 -> 60979 bytes .../kerning.plist | 21 + .../layercontents.plist | 30 + .../MutatorSansLightCondensed.ufo/lib.plist | 644 ++++++++++++++++++ .../metainfo.plist | 10 + .../MutatorSansLightWide.ufo/features.fea | 3 + .../MutatorSansLightWide.ufo/fontinfo.plist | 65 ++ .../glyphs.background/S_.closed.glif | 52 ++ .../glyphs.background/contents.plist | 8 + .../glyphs.background/layerinfo.plist | 8 + .../MutatorSansLightWide.ufo/glyphs/A_.glif | 43 ++ .../glyphs/A_acute.glif | 15 + .../glyphs/A_dieresis.glif | 15 + .../MutatorSansLightWide.ufo/glyphs/B_.glif | 49 ++ .../MutatorSansLightWide.ufo/glyphs/C_.glif | 39 ++ .../MutatorSansLightWide.ufo/glyphs/D_.glif | 33 + .../MutatorSansLightWide.ufo/glyphs/E_.glif | 44 ++ .../MutatorSansLightWide.ufo/glyphs/F_.glif | 32 + .../MutatorSansLightWide.ufo/glyphs/G_.glif | 48 ++ .../MutatorSansLightWide.ufo/glyphs/H_.glif | 25 + .../MutatorSansLightWide.ufo/glyphs/I_.glif | 25 + .../glyphs/I_.narrow.glif | 24 + .../MutatorSansLightWide.ufo/glyphs/I_J_.glif | 32 + .../MutatorSansLightWide.ufo/glyphs/J_.glif | 27 + .../glyphs/J_.narrow.glif | 24 + .../MutatorSansLightWide.ufo/glyphs/K_.glif | 28 + .../MutatorSansLightWide.ufo/glyphs/L_.glif | 19 + .../MutatorSansLightWide.ufo/glyphs/M_.glif | 29 + .../MutatorSansLightWide.ufo/glyphs/N_.glif | 25 + .../MutatorSansLightWide.ufo/glyphs/O_.glif | 39 ++ .../MutatorSansLightWide.ufo/glyphs/P_.glif | 33 + .../MutatorSansLightWide.ufo/glyphs/Q_.glif | 14 + .../MutatorSansLightWide.ufo/glyphs/R_.glif | 42 ++ .../glyphs/S_.closed.glif | 52 ++ .../MutatorSansLightWide.ufo/glyphs/S_.glif | 65 ++ .../MutatorSansLightWide.ufo/glyphs/T_.glif | 19 + .../MutatorSansLightWide.ufo/glyphs/U_.glif | 27 + .../MutatorSansLightWide.ufo/glyphs/V_.glif | 25 + .../MutatorSansLightWide.ufo/glyphs/W_.glif | 25 + .../MutatorSansLightWide.ufo/glyphs/X_.glif | 25 + .../MutatorSansLightWide.ufo/glyphs/Y_.glif | 25 + .../MutatorSansLightWide.ufo/glyphs/Z_.glif | 25 + .../glyphs/_notdef.glif | 42 ++ .../glyphs/acute.glif | 13 + .../glyphs/arrowdown.glif | 18 + .../glyphs/arrowleft.glif | 18 + .../glyphs/arrowright.glif | 18 + .../glyphs/arrowup.glif | 18 + .../MutatorSansLightWide.ufo/glyphs/b.glif | 7 + .../MutatorSansLightWide.ufo/glyphs/c.glif | 7 + .../glyphs/colon.glif | 9 + .../glyphs/comma.glif | 17 + .../glyphs/contents.plist | 104 +++ .../MutatorSansLightWide.ufo/glyphs/d.glif | 7 + .../glyphs/dieresis.glif | 15 + .../MutatorSansLightWide.ufo/glyphs/dot.glif | 13 + .../glyphs/layerinfo.plist | 13 + .../glyphs/period.glif | 13 + .../glyphs/quotedblbase.glif | 9 + .../glyphs/quotedblleft.glif | 9 + .../glyphs/quotedblright.glif | 9 + .../glyphs/quotesinglbase.glif | 8 + .../glyphs/semicolon.glif | 9 + .../glyphs/space.glif | 7 + .../MutatorSansLightWide.ufo/groups.plist | 20 + .../MutatorSansLightWide.ufo/kerning.plist | 210 ++++++ .../layercontents.plist | 14 + .../MutatorSansLightWide.ufo/lib.plist | 623 +++++++++++++++++ .../MutatorSansLightWide.ufo/metainfo.plist | 10 + 295 files changed, 9805 insertions(+), 194 deletions(-) create mode 100644 fixtures/fonts/mutatorsans-variable/LICENSE delete mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/fontinfo.plist delete mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/A_.glif delete mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/contents.plist delete mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/layercontents.plist delete mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/metainfo.plist delete mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/fontinfo.plist delete mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/glyphs/A_.glif delete mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/layercontents.plist delete mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/metainfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/features.fea create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/fontinfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs.background/S_.closed.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs.background/contents.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs.background/layerinfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/A_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/A_acute.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/A_dieresis.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/B_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/C_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/D_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/E_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/F_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/G_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/H_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/I_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/I_.narrow.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/I_J_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/J_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/J_.narrow.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/K_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/L_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/M_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/N_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/O_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/P_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/Q_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/R_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/S_.closed.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/S_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/T_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/U_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/V_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/W_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/X_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/Y_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/Z_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/_notdef.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/acute.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/arrowdown.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/arrowleft.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/arrowright.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/arrowup.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/b.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/c.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/colon.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/comma.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/contents.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/d.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/dieresis.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/dot.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/layerinfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/period.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/quotedblbase.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/quotedblleft.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/quotedblright.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/quotesinglbase.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/semicolon.glif rename fixtures/fonts/mutatorsans-variable/{MutatorSans-Light.ufo => MutatorSansBoldCondensed.ufo}/glyphs/space.glif (83%) create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/groups.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/kerning.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/layercontents.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/lib.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/metainfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/features.fea create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/fontinfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.background/S_.closed.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.background/S_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.background/contents.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.background/layerinfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.crayon/S_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.crayon/contents.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.crayon/layerinfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/A_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/A_acute.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/A_dieresis.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/B_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/C_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/D_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/E_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/F_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/G_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/H_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/I_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/I_.narrow.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/I_J_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/J_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/J_.narrow.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/K_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/L_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/M_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/N_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/O_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/P_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/Q_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/R_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/S_.closed.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/S_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/T_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/U_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/V_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/W_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/X_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/Y_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/Z_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/_notdef.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/acute.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/arrowdown.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/arrowleft.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/arrowright.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/arrowup.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/colon.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/comma.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/contents.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/dieresis.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/dot.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/layerinfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/period.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/quotedblbase.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/quotedblleft.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/quotedblright.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/quotesinglbase.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/semicolon.glif rename fixtures/fonts/mutatorsans-variable/{MutatorSans-Bold.ufo => MutatorSansBoldWide.ufo}/glyphs/space.glif (67%) create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/groups.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/images/image.png rename fixtures/fonts/mutatorsans-variable/{MutatorSans-Light.ufo/glyphs/contents.plist => MutatorSansBoldWide.ufo/kerning.plist} (67%) create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/layercontents.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/lib.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/metainfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/features.fea create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/fontinfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.background/S_.closed.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.background/S_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.background/contents.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.background/layerinfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.middle/S_.closed.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.middle/contents.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.middle/layerinfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.wide/S_.closed.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.wide/S_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.wide/contents.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.wide/layerinfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/B_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/E_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/F_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/G_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/contents.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/layerinfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support/A_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support/S_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support/W_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support/contents.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support/layerinfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/A_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/A_acute.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/A_dieresis.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/B_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/C_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/D_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/E_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/F_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/G_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/H_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/I_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/I_.narrow.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/I_J_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/J_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/J_.narrow.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/K_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/L_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/M_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/N_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/O_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/P_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/Q_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/R_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/S_.closed.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/S_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/T_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/U_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/V_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/W_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/X_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/Y_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/Z_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/_notdef.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/acute.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/arrowdown.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/arrowleft.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/arrowright.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/arrowup.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/b.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/c.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/colon.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/comma.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/contents.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/d.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/dieresis.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/dot.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/layerinfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/period.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/quotedblbase.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/quotedblleft.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/quotedblright.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/quotesinglbase.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/semicolon.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/space.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/groups.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/images/image create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/images/image000000000000001 create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/kerning.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/layercontents.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/lib.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/metainfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/features.fea create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/fontinfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs.background/S_.closed.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs.background/contents.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs.background/layerinfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/A_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/A_acute.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/A_dieresis.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/B_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/C_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/D_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/E_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/F_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/G_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/H_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/I_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/I_.narrow.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/I_J_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/J_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/J_.narrow.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/K_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/L_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/M_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/N_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/O_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/P_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/Q_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/R_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/S_.closed.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/S_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/T_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/U_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/V_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/W_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/X_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/Y_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/Z_.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/_notdef.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/acute.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/arrowdown.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/arrowleft.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/arrowright.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/arrowup.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/b.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/c.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/colon.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/comma.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/contents.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/d.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/dieresis.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/dot.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/layerinfo.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/period.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/quotedblbase.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/quotedblleft.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/quotedblright.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/quotesinglbase.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/semicolon.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/space.glif create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/groups.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/kerning.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/layercontents.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/lib.plist create mode 100644 fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/metainfo.plist diff --git a/crates/shift-backends/src/designspace/reader.rs b/crates/shift-backends/src/designspace/reader.rs index 7904ab3c..89186441 100644 --- a/crates/shift-backends/src/designspace/reader.rs +++ b/crates/shift-backends/src/designspace/reader.rs @@ -1,7 +1,8 @@ use crate::traits::FontReader; use crate::ufo::UfoReader; use norad::designspace::DesignSpaceDocument; -use shift_ir::{Axis, Font, Layer, Location, Source}; +use shift_ir::{Axis, Font, Layer, LayerId, Location, Source}; +use std::collections::HashMap; use std::path::Path; pub struct DesignspaceReader; @@ -32,7 +33,6 @@ impl FontReader for DesignspaceReader { return Err("Designspace has no sources".to_string()); } - // Find the default source — the one whose location matches axis defaults. let default_idx = find_default_source_index(&doc); // Load the default source first to establish the base font. @@ -45,7 +45,6 @@ impl FontReader for DesignspaceReader { let ufo_reader = UfoReader::new(); let mut font = ufo_reader.load(default_ufo_str)?; - // Override metadata from the designspace source if available. if let Some(ref family) = default_ds_source.familyname { font.metadata_mut().family_name = Some(family.clone()); } @@ -76,7 +75,10 @@ impl FontReader for DesignspaceReader { default_ds_source.filename.clone(), )); - // Load each non-default source as a new layer. + // Cache loaded UFO fonts so we don't re-read the same file for support layers. + let mut ufo_cache: HashMap = HashMap::new(); + + // Load each non-default source. for (idx, ds_source) in doc.sources.iter().enumerate() { if idx == default_idx { continue; @@ -85,18 +87,38 @@ impl FontReader for DesignspaceReader { let ufo_path = ds_dir.join(&ds_source.filename); let ufo_str = ufo_path .to_str() - .ok_or_else(|| format!("Invalid UTF-8 in UFO path: {:?}", ufo_path))?; - - let source_font = ufo_reader.load(ufo_str)?; - let source_default_layer = source_font.default_layer_id(); + .ok_or_else(|| format!("Invalid UTF-8 in UFO path: {ufo_path:?}"))? + .to_string(); + + let source_font = match ufo_cache.get(&ufo_str) { + Some(f) => f, + None => { + let loaded = ufo_reader.load(&ufo_str)?; + ufo_cache.insert(ufo_str.clone(), loaded); + ufo_cache.get(&ufo_str).unwrap() + } + }; + + // Determine which layer from the source UFO to read. + let source_layer_id = match &ds_source.layer { + Some(layer_name) => { + find_layer_by_name(source_font, layer_name).ok_or_else(|| { + format!( + "Layer '{}' not found in '{}'", + layer_name, ds_source.filename + ) + })? + } + None => source_font.default_layer_id(), + }; let name = source_name(ds_source, idx); let layer = Layer::new(name.clone()); let layer_id = font.add_layer(layer); - // Copy glyphs from this source's default layer into the new layer. + // Copy glyphs from the resolved layer into the new layer. for (glyph_name, source_glyph) in source_font.glyphs() { - if let Some(source_layer) = source_glyph.layer(source_default_layer) { + if let Some(source_layer) = source_glyph.layer(source_layer_id) { if let Some(existing_glyph) = font.glyph_mut(glyph_name) { existing_glyph.set_layer(layer_id, source_layer.clone()); } @@ -130,7 +152,6 @@ fn location_from_dimensions( ) -> Location { let mut location = Location::new(); for dim in dimensions { - // Dimension uses axis name; we need the axis tag. let value = dim.xvalue.unwrap_or(0.0) as f64; if let Some(axis) = doc.axes.iter().find(|a| a.name == dim.name) { location.set(axis.tag.clone(), value); @@ -140,8 +161,12 @@ fn location_from_dimensions( } fn find_default_source_index(doc: &DesignSpaceDocument) -> usize { - // The default source is the one whose location matches axis defaults. for (idx, source) in doc.sources.iter().enumerate() { + // Skip support layer sources. + if source.layer.is_some() { + continue; + } + let is_default = doc.axes.iter().all(|axis| { source .location @@ -157,6 +182,12 @@ fn find_default_source_index(doc: &DesignSpaceDocument) -> usize { return idx; } } - // Fall back to first source. 0 } + +fn find_layer_by_name(font: &Font, name: &str) -> Option { + font.layers() + .iter() + .find(|(_, layer)| layer.name() == name) + .map(|(&id, _)| id) +} diff --git a/crates/shift-backends/src/ufo/reader.rs b/crates/shift-backends/src/ufo/reader.rs index 3d8c6431..6937074a 100644 --- a/crates/shift-backends/src/ufo/reader.rs +++ b/crates/shift-backends/src/ufo/reader.rs @@ -239,8 +239,9 @@ impl FontReader for UfoReader { font.metrics_mut().x_height = norad_font.font_info.x_height; font.metrics_mut().italic_angle = norad_font.font_info.italic_angle; + let norad_default_layer_name = norad_font.layers.default_layer().name().clone(); for layer in norad_font.layers.iter() { - let layer_id = if layer.name().as_str() == "public.default" { + let layer_id = if layer.name() == &norad_default_layer_name { default_layer_id } else { let new_layer = Layer::new(layer.name().to_string()); diff --git a/crates/shift-core/tests/font_loading.rs b/crates/shift-core/tests/font_loading.rs index 1c49f3b5..5864f4ab 100644 --- a/crates/shift-core/tests/font_loading.rs +++ b/crates/shift-core/tests/font_loading.rs @@ -595,7 +595,10 @@ fn test_designspace_loads() { .expect("Failed to load designspace"); assert!(font.is_variable(), "Designspace font should be variable"); - assert_eq!(font.glyph_count(), 2, "Should have 2 glyphs (A, space)"); + assert!( + font.glyph_count() > 10, + "MutatorSans should have many glyphs" + ); } #[test] @@ -605,12 +608,13 @@ fn test_designspace_axes() { let font = loader.read_font(path.to_str().unwrap()).unwrap(); let axes = font.axes(); - assert_eq!(axes.len(), 1); - assert_eq!(axes[0].tag(), "wght"); - assert_eq!(axes[0].name(), "Weight"); - assert_eq!(axes[0].minimum(), 100.0); - assert_eq!(axes[0].maximum(), 900.0); - assert_eq!(axes[0].default(), 100.0); + assert_eq!(axes.len(), 2, "MutatorSans has width + weight axes"); + assert_eq!(axes[0].tag(), "wdth"); + assert_eq!(axes[0].name(), "width"); + assert_eq!(axes[0].minimum(), 0.0); + assert_eq!(axes[0].maximum(), 1000.0); + assert_eq!(axes[1].tag(), "wght"); + assert_eq!(axes[1].name(), "weight"); } #[test] @@ -620,27 +624,32 @@ fn test_designspace_sources() { let font = loader.read_font(path.to_str().unwrap()).unwrap(); let sources = font.sources(); - assert_eq!(sources.len(), 2, "Should have 2 sources"); - - let light = &sources[0]; - assert_eq!(light.location().get("wght"), Some(100.0)); - assert!(light.filename().is_some()); + // 4 main masters + 3 support layer sources = 7 + assert_eq!( + sources.len(), + 7, + "Should have 7 sources (4 masters + 3 support)" + ); - let bold = &sources[1]; - assert_eq!(bold.location().get("wght"), Some(900.0)); + // Default source (LightCondensed) at (0, 0) + let default = &sources[0]; + assert_eq!(default.location().get("wdth"), Some(0.0)); + assert_eq!(default.location().get("wght"), Some(0.0)); + assert!(default.filename().is_some()); } #[test] -fn test_designspace_glyph_has_two_layers() { +fn test_designspace_glyph_has_multiple_layers() { let path = mutatorsans_designspace_path(); let loader = FontLoader::new(); let font = loader.read_font(path.to_str().unwrap()).unwrap(); let glyph_a = font.glyph("A").expect("Glyph 'A' should exist"); - assert_eq!( - glyph_a.layers().len(), - 2, - "Glyph A should have 2 layers (one per master)" + // At least 4 layers from the 4 main masters + assert!( + glyph_a.layers().len() >= 4, + "Glyph A should have at least 4 layers, got {}", + glyph_a.layers().len() ); } @@ -653,7 +662,7 @@ fn test_designspace_metadata_from_default_source() { let metadata = font.metadata(); assert_eq!( metadata.family_name.as_deref(), - Some("MutatorSans"), + Some("MutatorMathTest"), "Family name should come from designspace source" ); } diff --git a/crates/shift-core/tests/round_trip.rs b/crates/shift-core/tests/round_trip.rs index d729795d..98fdaa5d 100644 --- a/crates/shift-core/tests/round_trip.rs +++ b/crates/shift-core/tests/round_trip.rs @@ -755,12 +755,11 @@ fn test_ufo_round_trip_layer_glyph_counts() { } } - let reload_layer = reloaded + let (&reload_layer_id, _) = reloaded .layers() - .values() - .find(|l| l.name() == orig_name) + .iter() + .find(|(_, l)| l.name() == orig_name) .unwrap_or_else(|| panic!("Layer '{orig_name}' should exist")); - let reload_layer_id = reload_layer.id(); let mut reload_glyph_count = 0; for glyph in reloaded.glyphs().values() { diff --git a/crates/shift-node/__test__/font_integration.spec.mjs b/crates/shift-node/__test__/font_integration.spec.mjs index 281733d0..6b67a20c 100644 --- a/crates/shift-node/__test__/font_integration.spec.mjs +++ b/crates/shift-node/__test__/font_integration.spec.mjs @@ -581,26 +581,28 @@ describe("FontEngine Integration - Designspace", () => { const engine = new FontEngine(); engine.loadFont(MUTATORSANS_DESIGNSPACE); expect(engine.isVariable()).toBe(true); - expect(engine.getGlyphCount()).toBe(2); + expect(engine.getGlyphCount()).toBeGreaterThan(10); }); it("returns axes from designspace", () => { const engine = new FontEngine(); engine.loadFont(MUTATORSANS_DESIGNSPACE); const axes = JSON.parse(engine.getAxes()); - expect(axes).toHaveLength(1); - expect(axes[0].tag).toBe("wght"); - expect(axes[0].minimum).toBe(100); - expect(axes[0].maximum).toBe(900); + expect(axes).toHaveLength(2); + expect(axes[0].tag).toBe("wdth"); + expect(axes[0].minimum).toBe(0); + expect(axes[0].maximum).toBe(1000); + expect(axes[1].tag).toBe("wght"); }); it("returns sources from designspace", () => { const engine = new FontEngine(); engine.loadFont(MUTATORSANS_DESIGNSPACE); const sources = JSON.parse(engine.getSources()); - expect(sources).toHaveLength(2); - expect(sources[0].location.values.wght).toBe(100); - expect(sources[1].location.values.wght).toBe(900); + // 4 main masters + 3 support layers + expect(sources).toHaveLength(7); + expect(sources[0].location.values.wdth).toBe(0); + expect(sources[0].location.values.wght).toBe(0); }); it("returns master snapshots for glyph A", () => { @@ -609,8 +611,9 @@ describe("FontEngine Integration - Designspace", () => { const json = engine.getGlyphMasterSnapshots("A"); expect(json).not.toBeNull(); const masters = JSON.parse(json); - expect(masters).toHaveLength(2); - expect(masters[0].snapshot.contours).toHaveLength(2); - expect(masters[1].snapshot.contours).toHaveLength(2); + expect(masters.length).toBeGreaterThanOrEqual(4); + for (const m of masters) { + expect(m.snapshot.contours.length).toBeGreaterThan(0); + } }); }); diff --git a/fixtures/fonts/mutatorsans-variable/LICENSE b/fixtures/fonts/mutatorsans-variable/LICENSE new file mode 100644 index 00000000..affdc0ee --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Erik van Blokland + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/fontinfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/fontinfo.plist deleted file mode 100644 index 676cd1ba..00000000 --- a/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/fontinfo.plist +++ /dev/null @@ -1,20 +0,0 @@ - - - - - familyName - MutatorSans - styleName - Bold - unitsPerEm - 1000 - ascender - 800 - descender - -200 - capHeight - 700 - xHeight - 500 - - diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/A_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/A_.glif deleted file mode 100644 index f6af17f2..00000000 --- a/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/A_.glif +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/contents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/contents.plist deleted file mode 100644 index 05f6343f..00000000 --- a/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/contents.plist +++ /dev/null @@ -1,10 +0,0 @@ - - - - - A - A_.glif - space - space.glif - - diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/layercontents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/layercontents.plist deleted file mode 100644 index cf95d357..00000000 --- a/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/layercontents.plist +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - public.default - glyphs - - - diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/metainfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/metainfo.plist deleted file mode 100644 index 632695b5..00000000 --- a/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/metainfo.plist +++ /dev/null @@ -1,10 +0,0 @@ - - - - - creator - org.robofab.ufoLib - formatVersion - 3 - - diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/fontinfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/fontinfo.plist deleted file mode 100644 index 8bed42c0..00000000 --- a/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/fontinfo.plist +++ /dev/null @@ -1,20 +0,0 @@ - - - - - familyName - MutatorSans - styleName - Light - unitsPerEm - 1000 - ascender - 800 - descender - -200 - capHeight - 700 - xHeight - 500 - - diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/glyphs/A_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/glyphs/A_.glif deleted file mode 100644 index 847a9368..00000000 --- a/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/glyphs/A_.glif +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/layercontents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/layercontents.plist deleted file mode 100644 index cf95d357..00000000 --- a/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/layercontents.plist +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - public.default - glyphs - - - diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/metainfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/metainfo.plist deleted file mode 100644 index 632695b5..00000000 --- a/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/metainfo.plist +++ /dev/null @@ -1,10 +0,0 @@ - - - - - creator - org.robofab.ufoLib - formatVersion - 3 - - diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans.designspace b/fixtures/fonts/mutatorsans-variable/MutatorSans.designspace index ac354ad8..fc76d7db 100644 --- a/fixtures/fonts/mutatorsans-variable/MutatorSans.designspace +++ b/fixtures/fonts/mutatorsans-variable/MutatorSans.designspace @@ -1,18 +1,325 @@ - + - + + + + + + + + + + + + + + + + + - + + + + + - + + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.letterror.designspaceEditor.previewLocation + + weight + 146.73033142089844 + width + 58.626346588134766 + + com.letterror.designspaceEditor.previewText + FISH + com.letterror.longboard.interactionSources + + + width + horizontal + + + weight + vertical + + + com.letterror.mathModelPref + previewMutatorMath + com.letterror.skateboard.interactionSources + + horizontal + + width + + ignore + + vertical + + weight + + + com.letterror.skateboard.interestingLocation + + + + weight + 775.609 + width + 794.522 + + S1 + + + + weight + 855.549 + width + 795.978 + + S2 + + + + weight + 1194.939375384999 + width + 898.8087507107668 + + S3 + + + + weight + 161.67457510442006 + width + 404.05720203707176 + + This is horrible + + + + weight + 467.73409948979594 + width + 538.7016581632644 + + Stem == 200? + + + + weight + 658.597 + width + 93.05199999999985 + + My_new_location + + + com.letterror.skateboard.previewLocation + + weight + 177.33442834877678 + width + 747.3306156281592 + + com.superpolator.data + + axiscolors + + weight + + 0.5 + 0.5 + 0.5 + 1.0 + + width + + 0.5 + 0.5 + 0.5 + 1.0 + + + instancefolder + instances + lineInverted + + lineStacked + sequence + lineViewFilled + + previewtext + SUPER + snippets + + + designspaceEdit.notes + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/features.fea b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/features.fea new file mode 100644 index 00000000..f5f829a8 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/features.fea @@ -0,0 +1,3 @@ +# this is the feature from boldcondensed. +languagesystem DFLT dflt; +languagesystem latn dflt; diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/fontinfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/fontinfo.plist new file mode 100644 index 00000000..eba66dec --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/fontinfo.plist @@ -0,0 +1,68 @@ + + + + + ascender + 800 + capHeight + 800 + copyright + License same as MutatorMath. BSD 3-clause. [test-token: A] + descender + -200 + familyName + MutatorSans + guidelines + + italicAngle + 0 + openTypeNameLicense + License same as MutatorMath. BSD 3-clause. [test-token: A] + openTypeOS2VendorID + LTTR + postscriptBlueValues + + -10 + 0 + 800 + 810 + + postscriptDefaultWidthX + 500 + postscriptFamilyBlues + + postscriptFamilyOtherBlues + + postscriptFontName + MutatorSans-BoldCondensed + postscriptOtherBlues + + 500 + 520 + + postscriptSlantAngle + 0 + postscriptStemSnapH + + postscriptStemSnapV + + postscriptWindowsCharacterSet + 1 + styleMapFamilyName + + styleMapStyleName + regular + styleName + BoldCondensed + unitsPerEm + 1000 + versionMajor + 1 + versionMinor + 2 + xHeight + 500 + year + 2004 + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs.background/S_.closed.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs.background/S_.closed.glif new file mode 100644 index 00000000..12ce1bbc --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs.background/S_.closed.glif @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs.background/contents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs.background/contents.plist new file mode 100644 index 00000000..0ef1afc6 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs.background/contents.plist @@ -0,0 +1,8 @@ + + + + + S.closed + S_.closed.glif + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs.background/layerinfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs.background/layerinfo.plist new file mode 100644 index 00000000..7e385c7f --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs.background/layerinfo.plist @@ -0,0 +1,8 @@ + + + + + color + 0.5,1,0,0.7 + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/A_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/A_.glif new file mode 100644 index 00000000..306d8ee3 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/A_.glif @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.typemytype.robofont.Image.Brightness + 0 + com.typemytype.robofont.Image.Contrast + 1 + com.typemytype.robofont.Image.Saturation + 1 + com.typemytype.robofont.Image.Sharpness + 0.4 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/A_acute.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/A_acute.glif new file mode 100644 index 00000000..f0415223 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/A_acute.glif @@ -0,0 +1,15 @@ + + + + + + + + + + + public.markColor + 0.6567,0.6903,1,1 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/A_dieresis.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/A_dieresis.glif new file mode 100644 index 00000000..7109f724 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/A_dieresis.glif @@ -0,0 +1,15 @@ + + + + + + + + + + + public.markColor + 0.6567,0.6903,1,1 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/B_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/B_.glif new file mode 100644 index 00000000..e22f432c --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/B_.glif @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/C_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/C_.glif new file mode 100644 index 00000000..284dcf64 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/C_.glif @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.typemytype.robofont.Image.Brightness + 0 + com.typemytype.robofont.Image.Contrast + 1 + com.typemytype.robofont.Image.Saturation + 1 + com.typemytype.robofont.Image.Sharpness + 0.4 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/D_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/D_.glif new file mode 100644 index 00000000..a1da8180 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/D_.glif @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/E_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/E_.glif new file mode 100644 index 00000000..d127056b --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/E_.glif @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.typemytype.robofont.Image.Brightness + 0 + com.typemytype.robofont.Image.Contrast + 1 + com.typemytype.robofont.Image.Saturation + 1 + com.typemytype.robofont.Image.Sharpness + 0.4 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/F_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/F_.glif new file mode 100644 index 00000000..8ed63e71 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/F_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/G_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/G_.glif new file mode 100644 index 00000000..48fd1a19 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/G_.glif @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/H_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/H_.glif new file mode 100644 index 00000000..e66768ac --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/H_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/I_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/I_.glif new file mode 100644 index 00000000..718d4872 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/I_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/I_.narrow.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/I_.narrow.glif new file mode 100644 index 00000000..d5643b85 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/I_.narrow.glif @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + com.typemytype.robofont.Image.Brightness + 0 + com.typemytype.robofont.Image.Contrast + 1 + com.typemytype.robofont.Image.Saturation + 1 + com.typemytype.robofont.Image.Sharpness + 0.4 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/I_J_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/I_J_.glif new file mode 100644 index 00000000..940e47c9 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/I_J_.glif @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/J_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/J_.glif new file mode 100644 index 00000000..c4314c24 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/J_.glif @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/J_.narrow.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/J_.narrow.glif new file mode 100644 index 00000000..7a338337 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/J_.narrow.glif @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/K_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/K_.glif new file mode 100644 index 00000000..6ab9e08e --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/K_.glif @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/L_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/L_.glif new file mode 100644 index 00000000..a42f84a9 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/L_.glif @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/M_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/M_.glif new file mode 100644 index 00000000..fb238308 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/M_.glif @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/N_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/N_.glif new file mode 100644 index 00000000..cb50a6d5 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/N_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/O_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/O_.glif new file mode 100644 index 00000000..27def6a1 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/O_.glif @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/P_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/P_.glif new file mode 100644 index 00000000..66ed04c7 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/P_.glif @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/Q_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/Q_.glif new file mode 100644 index 00000000..dd1417eb --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/Q_.glif @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/R_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/R_.glif new file mode 100644 index 00000000..952b71e0 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/R_.glif @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.typemytype.robofont.Image.Brightness + 0 + com.typemytype.robofont.Image.Contrast + 1 + com.typemytype.robofont.Image.Saturation + 1 + com.typemytype.robofont.Image.Sharpness + 0.4 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/S_.closed.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/S_.closed.glif new file mode 100644 index 00000000..d5e09976 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/S_.closed.glif @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.letterror.skateboard.navigator + + location + + weight + 1000.0 + width + 108.00694056919657 + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/S_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/S_.glif new file mode 100644 index 00000000..a13b97f0 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/S_.glif @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.typemytype.robofont.Image.Brightness + 0 + com.typemytype.robofont.Image.Contrast + 1 + com.typemytype.robofont.Image.Saturation + 1 + com.typemytype.robofont.Image.Sharpness + 0.4 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/T_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/T_.glif new file mode 100644 index 00000000..3d3e2c8f --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/T_.glif @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/U_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/U_.glif new file mode 100644 index 00000000..6752b492 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/U_.glif @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/V_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/V_.glif new file mode 100644 index 00000000..01087515 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/V_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/W_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/W_.glif new file mode 100644 index 00000000..77d4b763 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/W_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/X_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/X_.glif new file mode 100644 index 00000000..ab7b8723 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/X_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/Y_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/Y_.glif new file mode 100644 index 00000000..1a549a79 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/Y_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/Z_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/Z_.glif new file mode 100644 index 00000000..7e544c78 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/Z_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/_notdef.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/_notdef.glif new file mode 100644 index 00000000..7bd7a779 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/_notdef.glif @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + public.markColor + 0.6567,0.6903,1,1 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/acute.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/acute.glif new file mode 100644 index 00000000..ce8be622 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/acute.glif @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/arrowdown.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/arrowdown.glif new file mode 100644 index 00000000..015ae983 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/arrowdown.glif @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/arrowleft.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/arrowleft.glif new file mode 100644 index 00000000..9e425897 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/arrowleft.glif @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/arrowright.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/arrowright.glif new file mode 100644 index 00000000..2211aee8 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/arrowright.glif @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/arrowup.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/arrowup.glif new file mode 100644 index 00000000..ca8b87bc --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/arrowup.glif @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/b.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/b.glif new file mode 100644 index 00000000..16accedd --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/b.glif @@ -0,0 +1,7 @@ + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/c.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/c.glif new file mode 100644 index 00000000..0b4b3ccf --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/c.glif @@ -0,0 +1,7 @@ + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/colon.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/colon.glif new file mode 100644 index 00000000..8c164fcb --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/colon.glif @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/comma.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/comma.glif new file mode 100644 index 00000000..c43e1904 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/comma.glif @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + public.markColor + 0,0.95,0.95,1 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/contents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/contents.plist new file mode 100644 index 00000000..45273dee --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/contents.plist @@ -0,0 +1,104 @@ + + + + + .notdef + _notdef.glif + A + A_.glif + Aacute + A_acute.glif + Adieresis + A_dieresis.glif + B + B_.glif + C + C_.glif + D + D_.glif + E + E_.glif + F + F_.glif + G + G_.glif + H + H_.glif + I + I_.glif + I.narrow + I_.narrow.glif + IJ + I_J_.glif + J + J_.glif + J.narrow + J_.narrow.glif + K + K_.glif + L + L_.glif + M + M_.glif + N + N_.glif + O + O_.glif + P + P_.glif + Q + Q_.glif + R + R_.glif + S + S_.glif + S.closed + S_.closed.glif + T + T_.glif + U + U_.glif + V + V_.glif + W + W_.glif + X + X_.glif + Y + Y_.glif + Z + Z_.glif + acute + acute.glif + arrowdown + arrowdown.glif + arrowleft + arrowleft.glif + arrowright + arrowright.glif + arrowup + arrowup.glif + colon + colon.glif + comma + comma.glif + dieresis + dieresis.glif + dot + dot.glif + period + period.glif + quotedblbase + quotedblbase.glif + quotedblleft + quotedblleft.glif + quotedblright + quotedblright.glif + quotesinglbase + quotesinglbase.glif + semicolon + semicolon.glif + space + space.glif + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/d.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/d.glif new file mode 100644 index 00000000..8fc09610 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/d.glif @@ -0,0 +1,7 @@ + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/dieresis.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/dieresis.glif new file mode 100644 index 00000000..52693962 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/dieresis.glif @@ -0,0 +1,15 @@ + + + + + + + + + + + public.markColor + 0.6567,0.6903,1,1 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/dot.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/dot.glif new file mode 100644 index 00000000..8db5cc99 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/dot.glif @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/layerinfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/layerinfo.plist new file mode 100644 index 00000000..aeeb1b2a --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/layerinfo.plist @@ -0,0 +1,13 @@ + + + + + color + 1,0.75,0,0.7 + lib + + com.typemytype.robofont.segmentType + curve + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/period.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/period.glif new file mode 100644 index 00000000..0a0c3be1 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/period.glif @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + public.markColor + 0,0.95,0.95,1 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/quotedblbase.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/quotedblbase.glif new file mode 100644 index 00000000..d312caeb --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/quotedblbase.glif @@ -0,0 +1,21 @@ + + + + + + + + + + + com.typemytype.robofont.Image.Brightness + 0 + com.typemytype.robofont.Image.Contrast + 1 + com.typemytype.robofont.Image.Saturation + 1 + com.typemytype.robofont.Image.Sharpness + 0.4 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/quotedblleft.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/quotedblleft.glif new file mode 100644 index 00000000..3e2676ee --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/quotedblleft.glif @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/quotedblright.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/quotedblright.glif new file mode 100644 index 00000000..a65d160b --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/quotedblright.glif @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/quotesinglbase.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/quotesinglbase.glif new file mode 100644 index 00000000..93c283a3 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/quotesinglbase.glif @@ -0,0 +1,20 @@ + + + + + + + + + + com.typemytype.robofont.Image.Brightness + 0 + com.typemytype.robofont.Image.Contrast + 1 + com.typemytype.robofont.Image.Saturation + 1 + com.typemytype.robofont.Image.Sharpness + 0.4 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/semicolon.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/semicolon.glif new file mode 100644 index 00000000..c912a8d1 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/semicolon.glif @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/glyphs/space.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/space.glif similarity index 83% rename from fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/glyphs/space.glif rename to fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/space.glif index f5681cd2..80c661f7 100644 --- a/fixtures/fonts/mutatorsans-variable/MutatorSans-Light.ufo/glyphs/space.glif +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/glyphs/space.glif @@ -2,4 +2,6 @@ + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/groups.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/groups.plist new file mode 100644 index 00000000..7dc9fccd --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/groups.plist @@ -0,0 +1,20 @@ + + + + + public.kern1.@MMK_L_A + + A + + public.kern2.@MMK_R_A + + A + + testGroup + + E + F + H + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/kerning.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/kerning.plist new file mode 100644 index 00000000..f5fa18fa --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/kerning.plist @@ -0,0 +1,210 @@ + + + + + A + + J + -20 + O + -30 + T + -70 + U + -30 + V + -50 + + B + + A + -20 + J + -50 + O + -20 + S + -10 + T + -10 + U + -20 + V + -30 + + C + + A + -20 + J + -50 + T + -20 + V + -20 + + E + + J + -20 + T + -10 + V + -10 + + F + + A + -40 + J + -80 + O + -10 + S + -20 + U + -10 + V + -10 + + G + + J + -20 + S + -10 + T + -40 + U + -10 + V + -30 + + H + + J + -30 + S + -10 + T + -10 + + J + + J + -70 + + L + + J + -20 + O + -20 + T + -110 + U + -20 + V + -60 + + O + + A + -30 + J + -60 + S + -10 + T + -30 + V + -30 + + P + + A + -50 + J + -100 + S + -10 + T + -10 + U + -10 + V + -20 + + R + + H + -10 + J + -20 + O + -30 + S + -20 + T + -30 + U + -30 + V + -40 + + S + + A + -20 + H + -20 + J + -40 + O + -10 + S + -10 + T + -30 + U + -10 + V + -30 + W + -10 + + T + + A + -65 + H + -10 + J + -130 + O + -20 + + U + + A + -30 + J + -60 + S + -10 + V + -10 + + V + + J + -100 + O + -30 + S + -20 + U + -10 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/layercontents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/layercontents.plist new file mode 100644 index 00000000..e9a336b2 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/layercontents.plist @@ -0,0 +1,14 @@ + + + + + + foreground + glyphs + + + background + glyphs.background + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/lib.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/lib.plist new file mode 100644 index 00000000..f3fa5b5f --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/lib.plist @@ -0,0 +1,229 @@ + + + + + com.defcon.sortDescriptor + + + allowPseudoUnicode + + type + cannedDesign + + + com.letterror.lightMeter.prefs + + chunkSize + 5 + diameter + 200 + drawTail + + invert + + toolDiameter + 30 + toolStyle + fluid + + com.typemytype.robofont.background.layerStrokeColor + + 0.0 + 0.8 + 0.2 + 0.7 + + com.typemytype.robofont.compileSettings.autohint + + com.typemytype.robofont.compileSettings.checkOutlines + + com.typemytype.robofont.compileSettings.createDummyDSIG + + com.typemytype.robofont.compileSettings.decompose + + com.typemytype.robofont.compileSettings.generateFormat + 0 + com.typemytype.robofont.compileSettings.releaseMode + + com.typemytype.robofont.foreground.layerStrokeColor + + 0.5 + 0.0 + 0.5 + 0.7 + + com.typemytype.robofont.italicSlantOffset + 0 + com.typemytype.robofont.segmentType + curve + com.typemytype.robofont.shouldAddPointsInSplineConversion + 1 + com.typesupply.defcon.sortDescriptor + + + ascending + + space + A + B + C + D + E + F + G + H + I + J + K + L + M + N + O + P + Q + R + S + T + U + V + W + X + Y + Z + a + b + c + d + e + f + g + h + i + j + k + l + m + n + ntilde + o + p + q + r + s + t + u + v + w + x + y + z + zcaron + zero + one + two + three + four + five + six + seven + eight + nine + underscore + hyphen + endash + emdash + parenleft + parenright + bracketleft + bracketright + braceleft + braceright + numbersign + percent + period + comma + colon + semicolon + exclam + question + slash + backslash + bar + at + ampersand + paragraph + bullet + dollar + trademark + fi + fl + .notdef + a_b_c + Atilde + Adieresis + Acircumflex + Aring + Ccedilla + Agrave + Aacute + quotedblright + quotedblleft + + type + glyphList + + + public.glyphOrder + + space + A + Aacute + Adieresis + B + C + D + E + F + G + H + I + J + K + L + M + N + O + P + Q + R + S + T + U + V + W + X + Y + Z + IJ + quotedblleft + quotedblright + quotesinglbase + quotedblbase + period + comma + colon + semicolon + dot + acute + dieresis + arrowdown + arrowleft + arrowright + arrowup + I.narrow + J.narrow + S.closed + .notdef + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/metainfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/metainfo.plist new file mode 100644 index 00000000..7b8b34ac --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldCondensed.ufo/metainfo.plist @@ -0,0 +1,10 @@ + + + + + creator + com.github.fonttools.ufoLib + formatVersion + 3 + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/features.fea b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/features.fea new file mode 100644 index 00000000..70ab5da3 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/features.fea @@ -0,0 +1,3 @@ +# this is the feature from BoldWide +languagesystem DFLT dflt; +languagesystem latn dflt; diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/fontinfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/fontinfo.plist new file mode 100644 index 00000000..760fc3da --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/fontinfo.plist @@ -0,0 +1,68 @@ + + + + + ascender + 800 + capHeight + 800 + copyright + License same as MutatorMath. BSD 3-clause. [test-token: B] + descender + -200 + familyName + MutatorSans + guidelines + + italicAngle + 0 + openTypeNameLicense + License same as MutatorMath. BSD 3-clause. [test-token: B] + openTypeOS2VendorID + LTTR + postscriptBlueValues + + -10 + 0 + 800 + 810 + + postscriptDefaultWidthX + 500 + postscriptFamilyBlues + + postscriptFamilyOtherBlues + + postscriptFontName + MutatorSans-BoldWide + postscriptOtherBlues + + 400 + 420 + + postscriptSlantAngle + 0 + postscriptStemSnapH + + postscriptStemSnapV + + postscriptWindowsCharacterSet + 1 + styleMapFamilyName + + styleMapStyleName + regular + styleName + BoldWide + unitsPerEm + 1000 + versionMajor + 1 + versionMinor + 2 + xHeight + 500 + year + 2004 + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.background/S_.closed.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.background/S_.closed.glif new file mode 100644 index 00000000..71c82094 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.background/S_.closed.glif @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.background/S_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.background/S_.glif new file mode 100644 index 00000000..5e0ed4d8 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.background/S_.glif @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.background/contents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.background/contents.plist new file mode 100644 index 00000000..701b513a --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.background/contents.plist @@ -0,0 +1,10 @@ + + + + + S + S_.glif + S.closed + S_.closed.glif + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.background/layerinfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.background/layerinfo.plist new file mode 100644 index 00000000..7e385c7f --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.background/layerinfo.plist @@ -0,0 +1,8 @@ + + + + + color + 0.5,1,0,0.7 + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.crayon/S_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.crayon/S_.glif new file mode 100644 index 00000000..3e708495 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.crayon/S_.glif @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.crayon/contents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.crayon/contents.plist new file mode 100644 index 00000000..41c1aaf9 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.crayon/contents.plist @@ -0,0 +1,8 @@ + + + + + S + S_.glif + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.crayon/layerinfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.crayon/layerinfo.plist new file mode 100644 index 00000000..321e50a8 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs.crayon/layerinfo.plist @@ -0,0 +1,8 @@ + + + + + color + 0,1,0.25,0.7 + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/A_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/A_.glif new file mode 100644 index 00000000..58f75c00 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/A_.glif @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/A_acute.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/A_acute.glif new file mode 100644 index 00000000..d220f280 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/A_acute.glif @@ -0,0 +1,15 @@ + + + + + + + + + + + public.markColor + 0.6567,0.6903,1,1 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/A_dieresis.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/A_dieresis.glif new file mode 100644 index 00000000..7862d2f1 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/A_dieresis.glif @@ -0,0 +1,15 @@ + + + + + + + + + + + public.markColor + 0.6567,0.6903,1,1 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/B_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/B_.glif new file mode 100644 index 00000000..70bc5a7c --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/B_.glif @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.typemytype.robofont.Image.Brightness + 0 + com.typemytype.robofont.Image.Contrast + 1 + com.typemytype.robofont.Image.Saturation + 1 + com.typemytype.robofont.Image.Sharpness + 0.4 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/C_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/C_.glif new file mode 100644 index 00000000..92eea0f1 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/C_.glif @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/D_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/D_.glif new file mode 100644 index 00000000..5a50cb1a --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/D_.glif @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/E_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/E_.glif new file mode 100644 index 00000000..1330903b --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/E_.glif @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/F_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/F_.glif new file mode 100644 index 00000000..c62c5a01 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/F_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/G_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/G_.glif new file mode 100644 index 00000000..5f1b1813 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/G_.glif @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/H_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/H_.glif new file mode 100644 index 00000000..f0a95e47 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/H_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/I_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/I_.glif new file mode 100644 index 00000000..a013c998 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/I_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/I_.narrow.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/I_.narrow.glif new file mode 100644 index 00000000..111c8b61 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/I_.narrow.glif @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + com.typemytype.robofont.Image.Brightness + 0 + com.typemytype.robofont.Image.Contrast + 1 + com.typemytype.robofont.Image.Saturation + 1 + com.typemytype.robofont.Image.Sharpness + 0.4 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/I_J_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/I_J_.glif new file mode 100644 index 00000000..072b87d8 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/I_J_.glif @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/J_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/J_.glif new file mode 100644 index 00000000..1e41faac --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/J_.glif @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/J_.narrow.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/J_.narrow.glif new file mode 100644 index 00000000..393a0310 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/J_.narrow.glif @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/K_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/K_.glif new file mode 100644 index 00000000..73f5683c --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/K_.glif @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/L_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/L_.glif new file mode 100644 index 00000000..bc9a4d94 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/L_.glif @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/M_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/M_.glif new file mode 100644 index 00000000..c16bdd6a --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/M_.glif @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/N_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/N_.glif new file mode 100644 index 00000000..ab808746 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/N_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/O_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/O_.glif new file mode 100644 index 00000000..acd36a1b --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/O_.glif @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/P_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/P_.glif new file mode 100644 index 00000000..1cc7ce1f --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/P_.glif @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/Q_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/Q_.glif new file mode 100644 index 00000000..99f124b3 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/Q_.glif @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/R_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/R_.glif new file mode 100644 index 00000000..74b245b1 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/R_.glif @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/S_.closed.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/S_.closed.glif new file mode 100644 index 00000000..0796d29e --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/S_.closed.glif @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.typemytype.robofont.Image.Brightness + 0 + com.typemytype.robofont.Image.Contrast + 1 + com.typemytype.robofont.Image.Saturation + 1 + com.typemytype.robofont.Image.Sharpness + 0.4 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/S_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/S_.glif new file mode 100644 index 00000000..c64d01f7 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/S_.glif @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.typemytype.robofont.Image.Brightness + 0 + com.typemytype.robofont.Image.Contrast + 1 + com.typemytype.robofont.Image.Saturation + 1 + com.typemytype.robofont.Image.Sharpness + 0.4 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/T_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/T_.glif new file mode 100644 index 00000000..36a192c8 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/T_.glif @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/U_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/U_.glif new file mode 100644 index 00000000..fcbb5284 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/U_.glif @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/V_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/V_.glif new file mode 100644 index 00000000..21a3bbef --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/V_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/W_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/W_.glif new file mode 100644 index 00000000..6d07876a --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/W_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/X_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/X_.glif new file mode 100644 index 00000000..0cdb44c4 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/X_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/Y_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/Y_.glif new file mode 100644 index 00000000..a3175182 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/Y_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/Z_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/Z_.glif new file mode 100644 index 00000000..3b9ef90e --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/Z_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/_notdef.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/_notdef.glif new file mode 100644 index 00000000..7bd7a779 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/_notdef.glif @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + public.markColor + 0.6567,0.6903,1,1 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/acute.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/acute.glif new file mode 100644 index 00000000..a61baaec --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/acute.glif @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/arrowdown.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/arrowdown.glif new file mode 100644 index 00000000..e3c8cd28 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/arrowdown.glif @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/arrowleft.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/arrowleft.glif new file mode 100644 index 00000000..764a5b40 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/arrowleft.glif @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/arrowright.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/arrowright.glif new file mode 100644 index 00000000..e107939b --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/arrowright.glif @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/arrowup.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/arrowup.glif new file mode 100644 index 00000000..f5dacf21 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/arrowup.glif @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/colon.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/colon.glif new file mode 100644 index 00000000..91e80845 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/colon.glif @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/comma.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/comma.glif new file mode 100644 index 00000000..b26d079b --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/comma.glif @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/contents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/contents.plist new file mode 100644 index 00000000..45273dee --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/contents.plist @@ -0,0 +1,104 @@ + + + + + .notdef + _notdef.glif + A + A_.glif + Aacute + A_acute.glif + Adieresis + A_dieresis.glif + B + B_.glif + C + C_.glif + D + D_.glif + E + E_.glif + F + F_.glif + G + G_.glif + H + H_.glif + I + I_.glif + I.narrow + I_.narrow.glif + IJ + I_J_.glif + J + J_.glif + J.narrow + J_.narrow.glif + K + K_.glif + L + L_.glif + M + M_.glif + N + N_.glif + O + O_.glif + P + P_.glif + Q + Q_.glif + R + R_.glif + S + S_.glif + S.closed + S_.closed.glif + T + T_.glif + U + U_.glif + V + V_.glif + W + W_.glif + X + X_.glif + Y + Y_.glif + Z + Z_.glif + acute + acute.glif + arrowdown + arrowdown.glif + arrowleft + arrowleft.glif + arrowright + arrowright.glif + arrowup + arrowup.glif + colon + colon.glif + comma + comma.glif + dieresis + dieresis.glif + dot + dot.glif + period + period.glif + quotedblbase + quotedblbase.glif + quotedblleft + quotedblleft.glif + quotedblright + quotedblright.glif + quotesinglbase + quotesinglbase.glif + semicolon + semicolon.glif + space + space.glif + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/dieresis.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/dieresis.glif new file mode 100644 index 00000000..67e2f61b --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/dieresis.glif @@ -0,0 +1,15 @@ + + + + + + + + + + + public.markColor + 0.6567,0.6903,1,1 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/dot.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/dot.glif new file mode 100644 index 00000000..8db5cc99 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/dot.glif @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/layerinfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/layerinfo.plist new file mode 100644 index 00000000..aeeb1b2a --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/layerinfo.plist @@ -0,0 +1,13 @@ + + + + + color + 1,0.75,0,0.7 + lib + + com.typemytype.robofont.segmentType + curve + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/period.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/period.glif new file mode 100644 index 00000000..e00bdfa1 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/period.glif @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/quotedblbase.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/quotedblbase.glif new file mode 100644 index 00000000..cc87c1ce --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/quotedblbase.glif @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/quotedblleft.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/quotedblleft.glif new file mode 100644 index 00000000..aee8eba1 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/quotedblleft.glif @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/quotedblright.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/quotedblright.glif new file mode 100644 index 00000000..54e4dbe1 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/quotedblright.glif @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/quotesinglbase.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/quotesinglbase.glif new file mode 100644 index 00000000..65a94da6 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/quotesinglbase.glif @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/semicolon.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/semicolon.glif new file mode 100644 index 00000000..3d21bb11 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/semicolon.glif @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/space.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/space.glif similarity index 67% rename from fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/space.glif rename to fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/space.glif index 60585b39..80c661f7 100644 --- a/fixtures/fonts/mutatorsans-variable/MutatorSans-Bold.ufo/glyphs/space.glif +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/glyphs/space.glif @@ -1,5 +1,7 @@ - + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/groups.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/groups.plist new file mode 100644 index 00000000..7dc9fccd --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/groups.plist @@ -0,0 +1,20 @@ + + + + + public.kern1.@MMK_L_A + + A + + public.kern2.@MMK_R_A + + A + + testGroup + + E + F + H + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/images/image.png b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/images/image.png new file mode 100644 index 0000000000000000000000000000000000000000..1168dae6c81d77235aaa6c54ab5551f5f82bf1cf GIT binary patch literal 101536 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 vl-eJ02O - A - A_.glif - space - space.glif + T + + public.kern2.@MMK_R_A + -150 + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/layercontents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/layercontents.plist new file mode 100644 index 00000000..e9a336b2 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/layercontents.plist @@ -0,0 +1,14 @@ + + + + + + foreground + glyphs + + + background + glyphs.background + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/lib.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/lib.plist new file mode 100644 index 00000000..1a869874 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/lib.plist @@ -0,0 +1,355 @@ + + + + + com.defcon.sortDescriptor + + + allowPseudoUnicode + + type + cannedDesign + + + com.letterror.lightMeter.prefs + + chunkSize + 5 + diameter + 200 + drawTail + + invert + + toolDiameter + 30 + toolStyle + fluid + + com.typemytype.robofont.background.layerStrokeColor + + 0.0 + 0.8 + 0.2 + 0.7 + + com.typemytype.robofont.compileSettings.autohint + + com.typemytype.robofont.compileSettings.checkOutlines + + com.typemytype.robofont.compileSettings.createDummyDSIG + + com.typemytype.robofont.compileSettings.decompose + + com.typemytype.robofont.compileSettings.generateFormat + 0 + com.typemytype.robofont.compileSettings.releaseMode + + com.typemytype.robofont.foreground.layerStrokeColor + + 0.5 + 0.0 + 0.5 + 0.7 + + com.typemytype.robofont.italicSlantOffset + 0 + com.typemytype.robofont.segmentType + curve + com.typemytype.robofont.shouldAddPointsInSplineConversion + 1 + com.typesupply.defcon.sortDescriptor + + + ascending + + space + A + Agrave + Aacute + Acircumflex + Atilde + Adieresis + Aring + B + C + Ccedilla + D + E + Egrave + Eacute + Ecircumflex + Edieresis + F + G + H + I + Igrave + Iacute + Icircumflex + Idieresis + J + K + L + M + N + Ntilde + O + Ograve + Oacute + Ocircumflex + Otilde + Odieresis + P + Q + R + S + Scaron + T + U + Ugrave + Uacute + Ucircumflex + Udieresis + V + W + X + Y + Yacute + Ydieresis + Z + Zcaron + AE + Eth + Oslash + Thorn + Lslash + OE + a + agrave + aacute + acircumflex + atilde + adieresis + aring + b + c + ccedilla + d + e + egrave + eacute + ecircumflex + edieresis + f + g + h + i + igrave + iacute + icircumflex + idieresis + j + k + l + m + n + ntilde + o + ograve + oacute + ocircumflex + otilde + odieresis + p + q + r + s + scaron + t + u + ugrave + uacute + ucircumflex + udieresis + v + w + x + y + yacute + ydieresis + z + zcaron + ordfeminine + ordmasculine + germandbls + ae + eth + oslash + thorn + dotlessi + lslash + oe + zero + one + two + three + four + five + six + seven + eight + nine + onesuperior + twosuperior + threesuperior + onequarter + onehalf + threequarters + underscore + hyphen + endash + emdash + parenleft + parenright + bracketleft + bracketright + braceleft + braceright + numbersign + percent + perthousand + quotesingle + quotedbl + quoteleft + quoteright + quotedblleft + quotedblright + quotesinglbase + quotedblbase + guilsinglleft + guilsinglright + guillemotleft + guillemotright + asterisk + dagger + daggerdbl + period + comma + colon + semicolon + ellipsis + exclam + exclamdown + question + questiondown + slash + backslash + fraction + bar + brokenbar + at + ampersand + section + paragraph + periodcentered + bullet + plus + minus + plusminus + divide + multiply + equal + less + greater + logicalnot + mu + dollar + cent + sterling + currency + yen + Euro + florin + asciicircum + asciitilde + acute + grave + hungarumlaut + circumflex + caron + breve + tilde + macron + dieresis + dotaccent + ring + cedilla + ogonek + copyright + registered + trademark + degree + fi + fl + .notdef + a_b_c + + type + glyphList + + + public.glyphOrder + + space + A + Aacute + Adieresis + B + C + D + E + F + G + H + I + J + K + L + M + N + O + P + Q + R + S + T + U + V + W + X + Y + Z + quotedblleft + quotedblright + quotesinglbase + quotedblbase + period + comma + colon + semicolon + dot + acute + dieresis + IJ + arrowdown + arrowleft + arrowright + arrowup + I.narrow + J.narrow + S.closed + .notdef + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/metainfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/metainfo.plist new file mode 100644 index 00000000..7b8b34ac --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansBoldWide.ufo/metainfo.plist @@ -0,0 +1,10 @@ + + + + + creator + com.github.fonttools.ufoLib + formatVersion + 3 + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/features.fea b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/features.fea new file mode 100644 index 00000000..81b14ace --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/features.fea @@ -0,0 +1,4 @@ +# this is the feature from lightCondensed +# Hi_this_is_the_feature. +languagesystem DFLT dflt; +languagesystem latn dflt; diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/fontinfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/fontinfo.plist new file mode 100644 index 00000000..d8dfd6fd --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/fontinfo.plist @@ -0,0 +1,67 @@ + + + + + ascender + 700 + capHeight + 700 + copyright + License same as MutatorMath. BSD 3-clause. [test-token: C] + descender + -200 + familyName + MutatorSans + guidelines + + italicAngle + 0 + openTypeNameLicense + License same as MutatorMath. BSD 3-clause. [test-token: C] + openTypeOS2VendorID + LTTR + postscriptBlueValues + + -10 + 0 + 700 + 710 + + postscriptDefaultWidthX + 500 + postscriptFamilyBlues + + postscriptFamilyOtherBlues + + postscriptFontName + MutatorMathTest-LightCondensed + postscriptFullName + MutatorMathTest LightCondensed + postscriptOtherBlues + + postscriptSlantAngle + 0 + postscriptStemSnapH + + postscriptStemSnapV + + postscriptWindowsCharacterSet + 1 + styleMapFamilyName + + styleMapStyleName + regular + styleName + LightCondensed + unitsPerEm + 1000 + versionMajor + 1 + versionMinor + 2 + xHeight + 500 + year + 2004 + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.background/S_.closed.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.background/S_.closed.glif new file mode 100644 index 00000000..260a24c2 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.background/S_.closed.glif @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.background/S_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.background/S_.glif new file mode 100644 index 00000000..ea1a7c6a --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.background/S_.glif @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.letterror.skateboard.navigator + + location + + weight + 820.0 + width + 1000 + + + com.typemytype.robofont.Image.Brightness + 0 + com.typemytype.robofont.Image.Contrast + 1 + com.typemytype.robofont.Image.Saturation + 1 + com.typemytype.robofont.Image.Sharpness + 0.4 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.background/contents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.background/contents.plist new file mode 100644 index 00000000..701b513a --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.background/contents.plist @@ -0,0 +1,10 @@ + + + + + S + S_.glif + S.closed + S_.closed.glif + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.background/layerinfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.background/layerinfo.plist new file mode 100644 index 00000000..f3d8faef --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.background/layerinfo.plist @@ -0,0 +1,8 @@ + + + + + color + 0,1,1,0.7 + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.middle/S_.closed.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.middle/S_.closed.glif new file mode 100644 index 00000000..16ae9f0b --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.middle/S_.closed.glif @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.letterror.skateboard.navigator + + location + + weight + 700.0 + width + 569.078 + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.middle/contents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.middle/contents.plist new file mode 100644 index 00000000..0ef1afc6 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.middle/contents.plist @@ -0,0 +1,8 @@ + + + + + S.closed + S_.closed.glif + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.middle/layerinfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.middle/layerinfo.plist new file mode 100644 index 00000000..ea0e39cd --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.middle/layerinfo.plist @@ -0,0 +1,8 @@ + + + + + color + 0.5,0,1,0.7 + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.wide/S_.closed.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.wide/S_.closed.glif new file mode 100644 index 00000000..c33656eb --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.wide/S_.closed.glif @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.letterror.skateboard.navigator + + location + + weight + 673.7998527960526 + width + 1000.0 + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.wide/S_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.wide/S_.glif new file mode 100644 index 00000000..2abba517 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.wide/S_.glif @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.letterror.skateboard.navigator + + location + + weight + 759.5997715404774 + width + 1000.0 + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.wide/contents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.wide/contents.plist new file mode 100644 index 00000000..701b513a --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.wide/contents.plist @@ -0,0 +1,10 @@ + + + + + S + S_.glif + S.closed + S_.closed.glif + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.wide/layerinfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.wide/layerinfo.plist new file mode 100644 index 00000000..9aafa333 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.S_.wide/layerinfo.plist @@ -0,0 +1,8 @@ + + + + + color + 0,0.25,1,0.7 + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/B_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/B_.glif new file mode 100644 index 00000000..3f24fb62 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/B_.glif @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.letterror.skateboard.navigator + + location + + weight + 715.728 + width + -138.956 + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/E_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/E_.glif new file mode 100644 index 00000000..ba5e5896 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/E_.glif @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.letterror.skateboard.navigator + + location + + weight + 715.728 + width + -138.956 + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/F_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/F_.glif new file mode 100644 index 00000000..32fc9ad7 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/F_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/G_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/G_.glif new file mode 100644 index 00000000..446a8ddf --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/G_.glif @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.letterror.skateboard.navigator + + location + + weight + 715.728 + width + -138.956 + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/contents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/contents.plist new file mode 100644 index 00000000..fc4dc072 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/contents.plist @@ -0,0 +1,14 @@ + + + + + B + B_.glif + E + E_.glif + F + F_.glif + G + G_.glif + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/layerinfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/layerinfo.plist new file mode 100644 index 00000000..321e50a8 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support.crossbar/layerinfo.plist @@ -0,0 +1,8 @@ + + + + + color + 0,1,0.25,0.7 + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support/A_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support/A_.glif new file mode 100644 index 00000000..935df41d --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support/A_.glif @@ -0,0 +1,31 @@ + + + + + + + + + com.letterror.skateboard.navigator + + location + + space + 0.0 + weight + 600.0 + width + 500.0 + + + com.typemytype.robofont.Image.Brightness + 0 + com.typemytype.robofont.Image.Contrast + 1 + com.typemytype.robofont.Image.Saturation + 1 + com.typemytype.robofont.Image.Sharpness + 0.4 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support/S_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support/S_.glif new file mode 100644 index 00000000..4aad20f6 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support/S_.glif @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.letterror.skateboard.navigator + + location + + space + 25.0 + weight + 707.6485770089287 + width + 181.31051199776795 + + + com.typemytype.robofont.Image.Brightness + 0 + com.typemytype.robofont.Image.Contrast + 1 + com.typemytype.robofont.Image.Saturation + 1 + com.typemytype.robofont.Image.Sharpness + 0.4 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support/W_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support/W_.glif new file mode 100644 index 00000000..06a4b755 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support/W_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support/contents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support/contents.plist new file mode 100644 index 00000000..eb200c68 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support/contents.plist @@ -0,0 +1,12 @@ + + + + + A + A_.glif + S + S_.glif + W + W_.glif + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support/layerinfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support/layerinfo.plist new file mode 100644 index 00000000..5ab4d3bc --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs.support/layerinfo.plist @@ -0,0 +1,13 @@ + + + + + color + 0,1,0.25,0.7 + lib + + com.typemytype.robofont.segmentType + curve + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/A_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/A_.glif new file mode 100644 index 00000000..a0de1496 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/A_.glif @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/A_acute.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/A_acute.glif new file mode 100644 index 00000000..70645b1e --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/A_acute.glif @@ -0,0 +1,15 @@ + + + + + + + + + + + public.markColor + 0.6567,0.6903,1,1 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/A_dieresis.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/A_dieresis.glif new file mode 100644 index 00000000..7b0e7d97 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/A_dieresis.glif @@ -0,0 +1,15 @@ + + + + + + + + + + + public.markColor + 0.6567,0.6903,1,1 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/B_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/B_.glif new file mode 100644 index 00000000..b93e9a9f --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/B_.glif @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/C_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/C_.glif new file mode 100644 index 00000000..46b3537b --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/C_.glif @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/D_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/D_.glif new file mode 100644 index 00000000..6c02122d --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/D_.glif @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/E_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/E_.glif new file mode 100644 index 00000000..a9b71bf6 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/E_.glif @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/F_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/F_.glif new file mode 100644 index 00000000..b9579326 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/F_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/G_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/G_.glif new file mode 100644 index 00000000..525fcc4b --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/G_.glif @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/H_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/H_.glif new file mode 100644 index 00000000..e9431fda --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/H_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/I_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/I_.glif new file mode 100644 index 00000000..58937754 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/I_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/I_.narrow.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/I_.narrow.glif new file mode 100644 index 00000000..6ad5aa8a --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/I_.narrow.glif @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + com.typemytype.robofont.Image.Brightness + 0 + com.typemytype.robofont.Image.Contrast + 1 + com.typemytype.robofont.Image.Saturation + 1 + com.typemytype.robofont.Image.Sharpness + 0.4 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/I_J_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/I_J_.glif new file mode 100644 index 00000000..72c2c50a --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/I_J_.glif @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/J_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/J_.glif new file mode 100644 index 00000000..5347fe26 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/J_.glif @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/J_.narrow.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/J_.narrow.glif new file mode 100644 index 00000000..8b094c92 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/J_.narrow.glif @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/K_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/K_.glif new file mode 100644 index 00000000..e871b0f9 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/K_.glif @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/L_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/L_.glif new file mode 100644 index 00000000..b8d8ce46 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/L_.glif @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/M_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/M_.glif new file mode 100644 index 00000000..e78048d7 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/M_.glif @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/N_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/N_.glif new file mode 100644 index 00000000..28771ffb --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/N_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/O_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/O_.glif new file mode 100644 index 00000000..04bd4642 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/O_.glif @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/P_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/P_.glif new file mode 100644 index 00000000..5c13beba --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/P_.glif @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/Q_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/Q_.glif new file mode 100644 index 00000000..17503c26 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/Q_.glif @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/R_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/R_.glif new file mode 100644 index 00000000..8f0b2c9c --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/R_.glif @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/S_.closed.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/S_.closed.glif new file mode 100644 index 00000000..880efcdb --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/S_.closed.glif @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/S_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/S_.glif new file mode 100644 index 00000000..a642d2c7 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/S_.glif @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.typemytype.robofont.Image.Brightness + 0 + com.typemytype.robofont.Image.Contrast + 1 + com.typemytype.robofont.Image.Saturation + 1 + com.typemytype.robofont.Image.Sharpness + 0.4 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/T_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/T_.glif new file mode 100644 index 00000000..b4674b21 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/T_.glif @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/U_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/U_.glif new file mode 100644 index 00000000..b2f892f7 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/U_.glif @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/V_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/V_.glif new file mode 100644 index 00000000..0c97296c --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/V_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/W_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/W_.glif new file mode 100644 index 00000000..89434d65 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/W_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/X_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/X_.glif new file mode 100644 index 00000000..38bb129c --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/X_.glif @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/Y_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/Y_.glif new file mode 100644 index 00000000..a42b73c5 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/Y_.glif @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + com.typemytype.robofont.Image.Brightness + 0 + com.typemytype.robofont.Image.Contrast + 1 + com.typemytype.robofont.Image.Saturation + 1 + com.typemytype.robofont.Image.Sharpness + 0.4 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/Z_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/Z_.glif new file mode 100644 index 00000000..e9f15351 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/Z_.glif @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/_notdef.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/_notdef.glif new file mode 100644 index 00000000..7bd7a779 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/_notdef.glif @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + public.markColor + 0.6567,0.6903,1,1 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/acute.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/acute.glif new file mode 100644 index 00000000..f6b2297f --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/acute.glif @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/arrowdown.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/arrowdown.glif new file mode 100644 index 00000000..7cc92fd1 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/arrowdown.glif @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + public.markColor + 0,0.95,0.95,1 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/arrowleft.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/arrowleft.glif new file mode 100644 index 00000000..e8395a8c --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/arrowleft.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + public.markColor + 0,0.95,0.95,1 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/arrowright.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/arrowright.glif new file mode 100644 index 00000000..93e92c10 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/arrowright.glif @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + public.markColor + 0,0.95,0.95,1 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/arrowup.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/arrowup.glif new file mode 100644 index 00000000..13f3a01e --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/arrowup.glif @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + public.markColor + 0,0.95,0.95,1 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/b.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/b.glif new file mode 100644 index 00000000..16accedd --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/b.glif @@ -0,0 +1,7 @@ + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/c.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/c.glif new file mode 100644 index 00000000..0b4b3ccf --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/c.glif @@ -0,0 +1,7 @@ + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/colon.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/colon.glif new file mode 100644 index 00000000..f9c7f8a7 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/colon.glif @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/comma.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/comma.glif new file mode 100644 index 00000000..df2b5a61 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/comma.glif @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/contents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/contents.plist new file mode 100644 index 00000000..45273dee --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/contents.plist @@ -0,0 +1,104 @@ + + + + + .notdef + _notdef.glif + A + A_.glif + Aacute + A_acute.glif + Adieresis + A_dieresis.glif + B + B_.glif + C + C_.glif + D + D_.glif + E + E_.glif + F + F_.glif + G + G_.glif + H + H_.glif + I + I_.glif + I.narrow + I_.narrow.glif + IJ + I_J_.glif + J + J_.glif + J.narrow + J_.narrow.glif + K + K_.glif + L + L_.glif + M + M_.glif + N + N_.glif + O + O_.glif + P + P_.glif + Q + Q_.glif + R + R_.glif + S + S_.glif + S.closed + S_.closed.glif + T + T_.glif + U + U_.glif + V + V_.glif + W + W_.glif + X + X_.glif + Y + Y_.glif + Z + Z_.glif + acute + acute.glif + arrowdown + arrowdown.glif + arrowleft + arrowleft.glif + arrowright + arrowright.glif + arrowup + arrowup.glif + colon + colon.glif + comma + comma.glif + dieresis + dieresis.glif + dot + dot.glif + period + period.glif + quotedblbase + quotedblbase.glif + quotedblleft + quotedblleft.glif + quotedblright + quotedblright.glif + quotesinglbase + quotesinglbase.glif + semicolon + semicolon.glif + space + space.glif + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/d.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/d.glif new file mode 100644 index 00000000..8fc09610 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/d.glif @@ -0,0 +1,7 @@ + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/dieresis.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/dieresis.glif new file mode 100644 index 00000000..765ef4c5 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/dieresis.glif @@ -0,0 +1,15 @@ + + + + + + + + + + + public.markColor + 0.6567,0.6903,1,1 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/dot.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/dot.glif new file mode 100644 index 00000000..8f14d475 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/dot.glif @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/layerinfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/layerinfo.plist new file mode 100644 index 00000000..aeeb1b2a --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/layerinfo.plist @@ -0,0 +1,13 @@ + + + + + color + 1,0.75,0,0.7 + lib + + com.typemytype.robofont.segmentType + curve + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/period.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/period.glif new file mode 100644 index 00000000..76242499 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/period.glif @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/quotedblbase.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/quotedblbase.glif new file mode 100644 index 00000000..5a007a80 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/quotedblbase.glif @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/quotedblleft.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/quotedblleft.glif new file mode 100644 index 00000000..528a8ab8 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/quotedblleft.glif @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/quotedblright.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/quotedblright.glif new file mode 100644 index 00000000..42feceea --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/quotedblright.glif @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/quotesinglbase.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/quotesinglbase.glif new file mode 100644 index 00000000..a451b737 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/quotesinglbase.glif @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/semicolon.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/semicolon.glif new file mode 100644 index 00000000..2140efd3 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/semicolon.glif @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/space.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/space.glif new file mode 100644 index 00000000..80c661f7 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/glyphs/space.glif @@ -0,0 +1,7 @@ + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/groups.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/groups.plist new file mode 100644 index 00000000..7dc9fccd --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/groups.plist @@ -0,0 +1,20 @@ + + + + + public.kern1.@MMK_L_A + + A + + public.kern2.@MMK_R_A + + A + + testGroup + + E + F + H + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/images/image b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/images/image new file mode 100644 index 0000000000000000000000000000000000000000..965d6cdcf63522d81e46a1e01032217fcee00e9d GIT binary patch literal 33538 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%0Da(*=_> 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 + + + + T + + public.kern2.@MMK_R_A + -75 + + V + + public.kern2.@MMK_R_A + -100 + + public.kern1.@MMK_L_A + + V + -15 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/layercontents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/layercontents.plist new file mode 100644 index 00000000..c2622431 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/layercontents.plist @@ -0,0 +1,30 @@ + + + + + + foreground + glyphs + + + support + glyphs.support + + + support.crossbar + glyphs.support.crossbar + + + background + glyphs.background + + + support.S.wide + glyphs.support.S_.wide + + + support.S.middle + glyphs.support.S_.middle + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/lib.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/lib.plist new file mode 100644 index 00000000..e8c27f6c --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/lib.plist @@ -0,0 +1,644 @@ + + + + + com.defcon.sortDescriptor + + + allowPseudoUnicode + + type + cannedDesign + + + com.letterror.lightMeter.prefs + + chunkSize + 5 + diameter + 200 + drawTail + + invert + + toolDiameter + 30 + toolStyle + fluid + + com.typemytype.robofont.background.layerStrokeColor + + 0.0 + 0.8 + 0.2 + 0.7 + + com.typemytype.robofont.compileSettings.autohint + + com.typemytype.robofont.compileSettings.checkOutlines + + com.typemytype.robofont.compileSettings.createDummyDSIG + + com.typemytype.robofont.compileSettings.decompose + + com.typemytype.robofont.compileSettings.generateFormat + 0 + com.typemytype.robofont.compileSettings.releaseMode + + com.typemytype.robofont.foreground.layerStrokeColor + + 0.5 + 0.0 + 0.5 + 0.7 + + com.typemytype.robofont.italicSlantOffset + 0 + com.typemytype.robofont.segmentType + curve + com.typemytype.robofont.shouldAddPointsInSplineConversion + 1 + com.typesupply.MetricsMachine4.groupColors + + @MMK_L_A + + 1.0 + 0.0 + 0.0 + 0.25 + + @MMK_L_C + + 1.0 + 0.5 + 0.0 + 0.25 + + @MMK_L_E + + 1.0 + 1.0 + 0.0 + 0.25 + + @MMK_L_I + + 0.0 + 1.0 + 0.0 + 0.25 + + @MMK_L_N + + 0.0 + 1.0 + 1.0 + 0.25 + + @MMK_L_O + + 0.0 + 0.5 + 1.0 + 0.25 + + @MMK_L_S + + 0.0 + 0.0 + 1.0 + 0.25 + + @MMK_L_U + + 0.5 + 0.0 + 1.0 + 0.25 + + @MMK_L_Y + + 1.0 + 0.0 + 1.0 + 0.25 + + @MMK_L_Z + + 1.0 + 0.0 + 0.5 + 0.25 + + @MMK_L_a + + 1.0 + 0.0 + 0.0 + 0.25 + + @MMK_L_c + + 1.0 + 0.5 + 0.0 + 0.25 + + @MMK_L_e + + 1.0 + 1.0 + 0.0 + 0.25 + + @MMK_L_i + + 0.0 + 1.0 + 0.0 + 0.25 + + @MMK_L_n + + 0.0 + 1.0 + 1.0 + 0.25 + + @MMK_L_o + + 0.0 + 0.5 + 1.0 + 0.25 + + @MMK_L_s + + 0.0 + 0.0 + 1.0 + 0.25 + + @MMK_L_u + + 0.5 + 0.0 + 1.0 + 0.25 + + @MMK_L_y + + 1.0 + 0.0 + 1.0 + 0.25 + + @MMK_L_z + + 1.0 + 0.0 + 0.5 + 0.25 + + @MMK_R_A + + 1.0 + 0.0 + 0.0 + 0.25 + + @MMK_R_C + + 1.0 + 0.5 + 0.0 + 0.25 + + @MMK_R_E + + 1.0 + 1.0 + 0.0 + 0.25 + + @MMK_R_I + + 0.0 + 1.0 + 0.0 + 0.25 + + @MMK_R_N + + 0.0 + 1.0 + 1.0 + 0.25 + + @MMK_R_O + + 0.0 + 0.5 + 1.0 + 0.25 + + @MMK_R_S + + 0.0 + 0.0 + 1.0 + 0.25 + + @MMK_R_U + + 0.5 + 0.0 + 1.0 + 0.25 + + @MMK_R_Y + + 1.0 + 0.0 + 1.0 + 0.25 + + @MMK_R_Z + + 1.0 + 0.0 + 0.5 + 0.25 + + @MMK_R_a + + 1.0 + 0.0 + 0.0 + 0.25 + + @MMK_R_c + + 1.0 + 0.5 + 0.0 + 0.25 + + @MMK_R_e + + 1.0 + 1.0 + 0.0 + 0.25 + + @MMK_R_i + + 0.0 + 1.0 + 0.0 + 0.25 + + @MMK_R_n + + 0.0 + 1.0 + 1.0 + 0.25 + + @MMK_R_o + + 0.0 + 0.5 + 1.0 + 0.25 + + @MMK_R_s + + 0.0 + 0.0 + 1.0 + 0.25 + + @MMK_R_u + + 0.5 + 0.0 + 1.0 + 0.25 + + @MMK_R_y + + 1.0 + 0.0 + 1.0 + 0.25 + + @MMK_R_z + + 1.0 + 0.0 + 0.5 + 0.25 + + + com.typesupply.defcon.sortDescriptor + + + ascending + + space + A + Agrave + Aacute + Acircumflex + Atilde + Adieresis + Aring + B + C + Ccedilla + D + E + Egrave + Eacute + Ecircumflex + Edieresis + F + G + H + I + Igrave + Iacute + Icircumflex + Idieresis + J + K + L + M + N + Ntilde + O + Ograve + Oacute + Ocircumflex + Otilde + Odieresis + P + Q + R + S + Scaron + T + U + Ugrave + Uacute + Ucircumflex + Udieresis + V + W + X + Y + Yacute + Ydieresis + Z + Zcaron + AE + Eth + Oslash + Thorn + Lslash + OE + a + agrave + aacute + acircumflex + atilde + adieresis + aring + b + c + ccedilla + d + e + egrave + eacute + ecircumflex + edieresis + f + g + h + i + igrave + iacute + icircumflex + idieresis + j + k + l + m + n + ntilde + o + ograve + oacute + ocircumflex + otilde + odieresis + p + q + r + s + scaron + t + u + ugrave + uacute + ucircumflex + udieresis + v + w + x + y + yacute + ydieresis + z + zcaron + ordfeminine + ordmasculine + germandbls + ae + eth + oslash + thorn + dotlessi + lslash + oe + mu + zero + one + two + three + four + five + six + seven + eight + nine + onesuperior + twosuperior + threesuperior + onequarter + onehalf + threequarters + underscore + hyphen + endash + emdash + parenleft + parenright + bracketleft + bracketright + braceleft + braceright + numbersign + percent + perthousand + quotesingle + quotedbl + quoteleft + quoteright + quotedblleft + quotedblright + quotesinglbase + quotedblbase + guilsinglleft + guilsinglright + asterisk + dagger + daggerdbl + period + comma + colon + semicolon + ellipsis + exclam + exclamdown + question + questiondown + slash + backslash + fraction + bar + brokenbar + at + ampersand + section + paragraph + periodcentered + bullet + plus + minus + plusminus + divide + multiply + equal + less + greater + logicalnot + dollar + cent + sterling + currency + yen + Euro + asciicircum + asciitilde + acute + grave + hungarumlaut + circumflex + caron + breve + tilde + macron + dieresis + dotaccent + ring + cedilla + ogonek + copyright + registered + trademark + degree + florin + guillemotleft + guillemotright + fi + fl + a_b_c + .notdef + + type + glyphList + + + public.glyphOrder + + space + A + Aacute + Adieresis + B + C + D + E + F + G + H + I + J + K + L + M + N + O + P + Q + R + S + T + U + V + W + X + Y + Z + quotedblleft + quotedblright + quotesinglbase + quotedblbase + period + comma + colon + semicolon + arrowleft + arrowup + arrowright + arrowdown + dot + acute + dieresis + IJ + I.narrow + J.narrow + S.closed + .notdef + + testLibItemKey + + a + b + c + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/metainfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/metainfo.plist new file mode 100644 index 00000000..7b8b34ac --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightCondensed.ufo/metainfo.plist @@ -0,0 +1,10 @@ + + + + + creator + com.github.fonttools.ufoLib + formatVersion + 3 + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/features.fea b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/features.fea new file mode 100644 index 00000000..124776d7 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/features.fea @@ -0,0 +1,3 @@ +# this is the feature from lightWide +languagesystem DFLT dflt; +languagesystem latn dflt; diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/fontinfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/fontinfo.plist new file mode 100644 index 00000000..222803ff --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/fontinfo.plist @@ -0,0 +1,65 @@ + + + + + ascender + 700 + capHeight + 700 + copyright + License same as MutatorMath. BSD 3-clause. [test-token: D] + descender + -200 + familyName + MutatorSans + guidelines + + italicAngle + 0 + openTypeNameLicense + License same as MutatorMath. BSD 3-clause. [test-token: D] + openTypeOS2VendorID + LTTR + postscriptBlueValues + + -10 + 0 + 700 + 710 + + postscriptDefaultWidthX + 500 + postscriptFamilyBlues + + postscriptFamilyOtherBlues + + postscriptFontName + MutatorSans-LightWide + postscriptOtherBlues + + postscriptSlantAngle + 0 + postscriptStemSnapH + + postscriptStemSnapV + + postscriptWindowsCharacterSet + 1 + styleMapFamilyName + + styleMapStyleName + regular + styleName + LightWide + unitsPerEm + 1000 + versionMajor + 1 + versionMinor + 2 + xHeight + 500 + year + 2004 + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs.background/S_.closed.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs.background/S_.closed.glif new file mode 100644 index 00000000..8ad86c9b --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs.background/S_.closed.glif @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs.background/contents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs.background/contents.plist new file mode 100644 index 00000000..0ef1afc6 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs.background/contents.plist @@ -0,0 +1,8 @@ + + + + + S.closed + S_.closed.glif + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs.background/layerinfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs.background/layerinfo.plist new file mode 100644 index 00000000..7e385c7f --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs.background/layerinfo.plist @@ -0,0 +1,8 @@ + + + + + color + 0.5,1,0,0.7 + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/A_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/A_.glif new file mode 100644 index 00000000..9bda24f8 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/A_.glif @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.typemytype.robofont.Image.Brightness + 0 + com.typemytype.robofont.Image.Contrast + 1 + com.typemytype.robofont.Image.Saturation + 1 + com.typemytype.robofont.Image.Sharpness + 0.4 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/A_acute.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/A_acute.glif new file mode 100644 index 00000000..cf8ade0e --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/A_acute.glif @@ -0,0 +1,15 @@ + + + + + + + + + + + public.markColor + 0.6567,0.6903,1,1 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/A_dieresis.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/A_dieresis.glif new file mode 100644 index 00000000..64d419b2 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/A_dieresis.glif @@ -0,0 +1,15 @@ + + + + + + + + + + + public.markColor + 0.6567,0.6903,1,1 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/B_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/B_.glif new file mode 100644 index 00000000..2df4bd12 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/B_.glif @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/C_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/C_.glif new file mode 100644 index 00000000..dfe52541 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/C_.glif @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/D_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/D_.glif new file mode 100644 index 00000000..7aab614c --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/D_.glif @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/E_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/E_.glif new file mode 100644 index 00000000..201d0def --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/E_.glif @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.typemytype.robofont.Image.Brightness + 0 + com.typemytype.robofont.Image.Contrast + 1 + com.typemytype.robofont.Image.Saturation + 1 + com.typemytype.robofont.Image.Sharpness + 0.4 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/F_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/F_.glif new file mode 100644 index 00000000..f1e68b8f --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/F_.glif @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.typemytype.robofont.guideline.magnetic.tCW6w1QdWl + 5 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/G_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/G_.glif new file mode 100644 index 00000000..f15c562d --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/G_.glif @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/H_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/H_.glif new file mode 100644 index 00000000..e6f12e3c --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/H_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/I_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/I_.glif new file mode 100644 index 00000000..fa3c5a78 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/I_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/I_.narrow.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/I_.narrow.glif new file mode 100644 index 00000000..fdf59866 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/I_.narrow.glif @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + com.typemytype.robofont.Image.Brightness + 0 + com.typemytype.robofont.Image.Contrast + 1 + com.typemytype.robofont.Image.Saturation + 1 + com.typemytype.robofont.Image.Sharpness + 0.4 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/I_J_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/I_J_.glif new file mode 100644 index 00000000..1b8dc0d9 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/I_J_.glif @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/J_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/J_.glif new file mode 100644 index 00000000..5c9c0927 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/J_.glif @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/J_.narrow.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/J_.narrow.glif new file mode 100644 index 00000000..9d39b581 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/J_.narrow.glif @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/K_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/K_.glif new file mode 100644 index 00000000..6f8061f5 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/K_.glif @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/L_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/L_.glif new file mode 100644 index 00000000..b1bb16e5 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/L_.glif @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/M_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/M_.glif new file mode 100644 index 00000000..3403d3f0 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/M_.glif @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/N_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/N_.glif new file mode 100644 index 00000000..6a7073de --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/N_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/O_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/O_.glif new file mode 100644 index 00000000..b5723838 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/O_.glif @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/P_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/P_.glif new file mode 100644 index 00000000..0625d631 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/P_.glif @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/Q_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/Q_.glif new file mode 100644 index 00000000..2a1da2a0 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/Q_.glif @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/R_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/R_.glif new file mode 100644 index 00000000..9b34050a --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/R_.glif @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/S_.closed.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/S_.closed.glif new file mode 100644 index 00000000..08a88124 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/S_.closed.glif @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/S_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/S_.glif new file mode 100644 index 00000000..dda8c480 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/S_.glif @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.typemytype.robofont.Image.Brightness + 0 + com.typemytype.robofont.Image.Contrast + 1 + com.typemytype.robofont.Image.Saturation + 1 + com.typemytype.robofont.Image.Sharpness + 0.4 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/T_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/T_.glif new file mode 100644 index 00000000..de53b29d --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/T_.glif @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/U_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/U_.glif new file mode 100644 index 00000000..f17919fa --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/U_.glif @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/V_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/V_.glif new file mode 100644 index 00000000..aa96bcef --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/V_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/W_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/W_.glif new file mode 100644 index 00000000..c0e4302e --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/W_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/X_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/X_.glif new file mode 100644 index 00000000..af8c9762 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/X_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/Y_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/Y_.glif new file mode 100644 index 00000000..c3aa70c1 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/Y_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/Z_.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/Z_.glif new file mode 100644 index 00000000..9c01301f --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/Z_.glif @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/_notdef.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/_notdef.glif new file mode 100644 index 00000000..7bd7a779 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/_notdef.glif @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + public.markColor + 0.6567,0.6903,1,1 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/acute.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/acute.glif new file mode 100644 index 00000000..f1b10c6d --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/acute.glif @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/arrowdown.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/arrowdown.glif new file mode 100644 index 00000000..8392d98c --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/arrowdown.glif @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/arrowleft.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/arrowleft.glif new file mode 100644 index 00000000..cdbd1cac --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/arrowleft.glif @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/arrowright.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/arrowright.glif new file mode 100644 index 00000000..55b68fb9 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/arrowright.glif @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/arrowup.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/arrowup.glif new file mode 100644 index 00000000..1545bd7e --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/arrowup.glif @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/b.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/b.glif new file mode 100644 index 00000000..16accedd --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/b.glif @@ -0,0 +1,7 @@ + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/c.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/c.glif new file mode 100644 index 00000000..0b4b3ccf --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/c.glif @@ -0,0 +1,7 @@ + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/colon.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/colon.glif new file mode 100644 index 00000000..e442b708 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/colon.glif @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/comma.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/comma.glif new file mode 100644 index 00000000..b214a2eb --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/comma.glif @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/contents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/contents.plist new file mode 100644 index 00000000..45273dee --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/contents.plist @@ -0,0 +1,104 @@ + + + + + .notdef + _notdef.glif + A + A_.glif + Aacute + A_acute.glif + Adieresis + A_dieresis.glif + B + B_.glif + C + C_.glif + D + D_.glif + E + E_.glif + F + F_.glif + G + G_.glif + H + H_.glif + I + I_.glif + I.narrow + I_.narrow.glif + IJ + I_J_.glif + J + J_.glif + J.narrow + J_.narrow.glif + K + K_.glif + L + L_.glif + M + M_.glif + N + N_.glif + O + O_.glif + P + P_.glif + Q + Q_.glif + R + R_.glif + S + S_.glif + S.closed + S_.closed.glif + T + T_.glif + U + U_.glif + V + V_.glif + W + W_.glif + X + X_.glif + Y + Y_.glif + Z + Z_.glif + acute + acute.glif + arrowdown + arrowdown.glif + arrowleft + arrowleft.glif + arrowright + arrowright.glif + arrowup + arrowup.glif + colon + colon.glif + comma + comma.glif + dieresis + dieresis.glif + dot + dot.glif + period + period.glif + quotedblbase + quotedblbase.glif + quotedblleft + quotedblleft.glif + quotedblright + quotedblright.glif + quotesinglbase + quotesinglbase.glif + semicolon + semicolon.glif + space + space.glif + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/d.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/d.glif new file mode 100644 index 00000000..8fc09610 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/d.glif @@ -0,0 +1,7 @@ + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/dieresis.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/dieresis.glif new file mode 100644 index 00000000..1eef4666 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/dieresis.glif @@ -0,0 +1,15 @@ + + + + + + + + + + + public.markColor + 0.6567,0.6903,1,1 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/dot.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/dot.glif new file mode 100644 index 00000000..8f14d475 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/dot.glif @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/layerinfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/layerinfo.plist new file mode 100644 index 00000000..aeeb1b2a --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/layerinfo.plist @@ -0,0 +1,13 @@ + + + + + color + 1,0.75,0,0.7 + lib + + com.typemytype.robofont.segmentType + curve + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/period.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/period.glif new file mode 100644 index 00000000..3a362b47 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/period.glif @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/quotedblbase.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/quotedblbase.glif new file mode 100644 index 00000000..fbfc8536 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/quotedblbase.glif @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/quotedblleft.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/quotedblleft.glif new file mode 100644 index 00000000..f34371e4 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/quotedblleft.glif @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/quotedblright.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/quotedblright.glif new file mode 100644 index 00000000..53e5d26a --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/quotedblright.glif @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/quotesinglbase.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/quotesinglbase.glif new file mode 100644 index 00000000..4ff1ffbb --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/quotesinglbase.glif @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/semicolon.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/semicolon.glif new file mode 100644 index 00000000..995849fd --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/semicolon.glif @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/space.glif b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/space.glif new file mode 100644 index 00000000..80c661f7 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/glyphs/space.glif @@ -0,0 +1,7 @@ + + + + + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/groups.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/groups.plist new file mode 100644 index 00000000..7dc9fccd --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/groups.plist @@ -0,0 +1,20 @@ + + + + + public.kern1.@MMK_L_A + + A + + public.kern2.@MMK_R_A + + A + + testGroup + + E + F + H + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/kerning.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/kerning.plist new file mode 100644 index 00000000..5fda98fd --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/kerning.plist @@ -0,0 +1,210 @@ + + + + + B + + H + -40 + J + -85 + O + -10 + S + -25 + T + -85 + U + -35 + V + -85 + public.kern2.@MMK_R_A + -25 + + C + + J + -75 + T + -40 + V + -65 + public.kern2.@MMK_R_A + -40 + + E + + J + -80 + T + -10 + + F + + H + -35 + J + -245 + O + -50 + S + -70 + U + -10 + public.kern2.@MMK_R_A + -125 + + G + + J + -60 + O + -15 + S + -15 + T + -105 + U + -20 + V + -70 + public.kern2.@MMK_R_A + -40 + + H + + J + -40 + S + -30 + + J + + J + -135 + O + -10 + public.kern2.@MMK_R_A + -70 + + L + + J + -25 + O + -95 + T + -305 + U + -85 + V + -185 + + O + + J + -120 + S + -45 + T + -90 + V + -80 + public.kern2.@MMK_R_A + -30 + + P + + J + -200 + T + -20 + public.kern2.@MMK_R_A + -95 + + R + + H + -45 + J + -155 + O + -55 + S + -70 + T + -80 + U + -60 + V + -95 + public.kern2.@MMK_R_A + -80 + + S + + H + -5 + J + -115 + O + -40 + S + -45 + T + -50 + U + -10 + V + -20 + public.kern2.@MMK_R_A + -45 + + T + + H + -15 + J + -315 + O + -90 + S + -25 + public.kern2.@MMK_R_A + -215 + + U + + J + -140 + public.kern2.@MMK_R_A + -75 + + V + + H + -20 + J + -265 + O + -55 + S + -50 + public.kern2.@MMK_R_A + -210 + + public.kern1.@MMK_L_A + + J + -35 + O + -55 + T + -190 + U + -95 + V + -180 + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/layercontents.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/layercontents.plist new file mode 100644 index 00000000..e9a336b2 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/layercontents.plist @@ -0,0 +1,14 @@ + + + + + + foreground + glyphs + + + background + glyphs.background + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/lib.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/lib.plist new file mode 100644 index 00000000..73160063 --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/lib.plist @@ -0,0 +1,623 @@ + + + + + com.defcon.sortDescriptor + + + allowPseudoUnicode + + type + cannedDesign + + + com.typemytype.robofont.background.layerStrokeColor + + 1.0 + 0.75 + 0.0 + 0.7 + + com.typemytype.robofont.compileSettings.autohint + + com.typemytype.robofont.compileSettings.checkOutlines + + com.typemytype.robofont.compileSettings.createDummyDSIG + + com.typemytype.robofont.compileSettings.decompose + + com.typemytype.robofont.compileSettings.generateFormat + 0 + com.typemytype.robofont.compileSettings.releaseMode + + com.typemytype.robofont.foreground.layerStrokeColor + + 0.5 + 0.0 + 0.5 + 0.7 + + com.typemytype.robofont.italicSlantOffset + 0 + com.typemytype.robofont.segmentType + curve + com.typemytype.robofont.shouldAddPointsInSplineConversion + 1 + com.typesupply.MetricsMachine4.groupColors + + @MMK_L_A + + 1.0 + 0.0 + 0.0 + 0.25 + + @MMK_L_C + + 1.0 + 0.5 + 0.0 + 0.25 + + @MMK_L_E + + 1.0 + 1.0 + 0.0 + 0.25 + + @MMK_L_I + + 0.0 + 1.0 + 0.0 + 0.25 + + @MMK_L_N + + 0.0 + 1.0 + 1.0 + 0.25 + + @MMK_L_O + + 0.0 + 0.5 + 1.0 + 0.25 + + @MMK_L_S + + 0.0 + 0.0 + 1.0 + 0.25 + + @MMK_L_U + + 0.5 + 0.0 + 1.0 + 0.25 + + @MMK_L_Y + + 1.0 + 0.0 + 1.0 + 0.25 + + @MMK_L_Z + + 1.0 + 0.0 + 0.5 + 0.25 + + @MMK_L_a + + 1.0 + 0.0 + 0.0 + 0.25 + + @MMK_L_c + + 1.0 + 0.5 + 0.0 + 0.25 + + @MMK_L_e + + 1.0 + 1.0 + 0.0 + 0.25 + + @MMK_L_i + + 0.0 + 1.0 + 0.0 + 0.25 + + @MMK_L_n + + 0.0 + 1.0 + 1.0 + 0.25 + + @MMK_L_o + + 0.0 + 0.5 + 1.0 + 0.25 + + @MMK_L_s + + 0.0 + 0.0 + 1.0 + 0.25 + + @MMK_L_u + + 0.5 + 0.0 + 1.0 + 0.25 + + @MMK_L_y + + 1.0 + 0.0 + 1.0 + 0.25 + + @MMK_L_z + + 1.0 + 0.0 + 0.5 + 0.25 + + @MMK_R_A + + 1.0 + 0.0 + 0.0 + 0.25 + + @MMK_R_C + + 1.0 + 0.5 + 0.0 + 0.25 + + @MMK_R_E + + 1.0 + 1.0 + 0.0 + 0.25 + + @MMK_R_I + + 0.0 + 1.0 + 0.0 + 0.25 + + @MMK_R_N + + 0.0 + 1.0 + 1.0 + 0.25 + + @MMK_R_O + + 0.0 + 0.5 + 1.0 + 0.25 + + @MMK_R_S + + 0.0 + 0.0 + 1.0 + 0.25 + + @MMK_R_U + + 0.5 + 0.0 + 1.0 + 0.25 + + @MMK_R_Y + + 1.0 + 0.0 + 1.0 + 0.25 + + @MMK_R_Z + + 1.0 + 0.0 + 0.5 + 0.25 + + @MMK_R_a + + 1.0 + 0.0 + 0.0 + 0.25 + + @MMK_R_c + + 1.0 + 0.5 + 0.0 + 0.25 + + @MMK_R_e + + 1.0 + 1.0 + 0.0 + 0.25 + + @MMK_R_i + + 0.0 + 1.0 + 0.0 + 0.25 + + @MMK_R_n + + 0.0 + 1.0 + 1.0 + 0.25 + + @MMK_R_o + + 0.0 + 0.5 + 1.0 + 0.25 + + @MMK_R_s + + 0.0 + 0.0 + 1.0 + 0.25 + + @MMK_R_u + + 0.5 + 0.0 + 1.0 + 0.25 + + @MMK_R_y + + 1.0 + 0.0 + 1.0 + 0.25 + + @MMK_R_z + + 1.0 + 0.0 + 0.5 + 0.25 + + + com.typesupply.defcon.sortDescriptor + + + ascending + + space + A + Agrave + Aacute + Acircumflex + Atilde + Adieresis + Aring + B + C + Ccedilla + D + E + Egrave + Eacute + Ecircumflex + Edieresis + F + G + H + I + Igrave + Iacute + Icircumflex + Idieresis + J + K + L + M + N + Ntilde + O + Ograve + Oacute + Ocircumflex + Otilde + Odieresis + P + Q + R + S + Scaron + T + U + Ugrave + Uacute + Ucircumflex + Udieresis + V + W + X + Y + Yacute + Ydieresis + Z + Zcaron + AE + Eth + Oslash + Thorn + Lslash + OE + a + agrave + aacute + acircumflex + atilde + adieresis + aring + b + c + ccedilla + d + e + egrave + eacute + ecircumflex + edieresis + f + g + h + i + igrave + iacute + icircumflex + idieresis + j + k + l + m + n + ntilde + o + ograve + oacute + ocircumflex + otilde + odieresis + p + q + r + s + scaron + t + u + ugrave + uacute + ucircumflex + udieresis + v + w + x + y + yacute + ydieresis + z + zcaron + ordfeminine + ordmasculine + germandbls + ae + eth + oslash + thorn + dotlessi + lslash + oe + zero + one + two + three + four + five + six + seven + eight + nine + onesuperior + twosuperior + threesuperior + onequarter + onehalf + threequarters + underscore + hyphen + endash + emdash + parenleft + parenright + bracketleft + bracketright + braceleft + braceright + numbersign + percent + perthousand + quotesingle + quotedbl + quoteleft + quoteright + quotedblleft + quotedblright + quotesinglbase + quotedblbase + guilsinglleft + guilsinglright + guillemotleft + guillemotright + asterisk + dagger + daggerdbl + period + comma + colon + semicolon + ellipsis + exclam + exclamdown + question + questiondown + slash + backslash + fraction + bar + brokenbar + at + ampersand + section + paragraph + periodcentered + bullet + plus + minus + plusminus + divide + multiply + equal + less + greater + logicalnot + mu + dollar + cent + sterling + currency + yen + Euro + florin + asciicircum + asciitilde + acute + grave + hungarumlaut + circumflex + caron + breve + tilde + macron + dieresis + dotaccent + ring + cedilla + ogonek + copyright + registered + trademark + degree + fi + fl + .notdef + a_b_c + + type + glyphList + + + public.glyphOrder + + space + A + Aacute + Adieresis + B + C + D + E + F + G + H + I + J + K + L + M + N + O + P + Q + R + S + T + U + V + W + X + Y + Z + quotedblleft + quotedblright + quotesinglbase + quotedblbase + period + comma + colon + semicolon + arrowleft + arrowup + arrowright + arrowdown + dot + acute + dieresis + IJ + I.narrow + J.narrow + S.closed + .notdef + + + diff --git a/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/metainfo.plist b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/metainfo.plist new file mode 100644 index 00000000..7b8b34ac --- /dev/null +++ b/fixtures/fonts/mutatorsans-variable/MutatorSansLightWide.ufo/metainfo.plist @@ -0,0 +1,10 @@ + + + + + creator + com.github.fonttools.ufoLib + formatVersion + 3 + + From 010257913bc40ef9eb589e9c7eace794b2f472aa Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 18:53:15 +0100 Subject: [PATCH 05/41] Always use default layer for glyph rendering and edit sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace preferred_layer_for_glyph (max complexity heuristic) with consistent default layer selection. Variable fonts have multiple layers per glyph — one per master — and the grid was showing random masters because HashMap iteration order is non-deterministic. Now the grid, SVG paths, edit sessions, and composite resolution all consistently use the font's default layer. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__test__/font_integration.spec.mjs | 8 ++-- crates/shift-node/src/font_engine.rs | 39 +++++++++---------- 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/crates/shift-node/__test__/font_integration.spec.mjs b/crates/shift-node/__test__/font_integration.spec.mjs index 6b67a20c..52816063 100644 --- a/crates/shift-node/__test__/font_integration.spec.mjs +++ b/crates/shift-node/__test__/font_integration.spec.mjs @@ -549,10 +549,10 @@ describe("FontEngine Integration - Variable Font (.glyphs)", () => { expect(m.snapshot.contours).toHaveLength(2); } - // Both masters should have matching point counts per contour - const lightCounts = masters[0].snapshot.contours.map((c) => c.points.length); - const boldCounts = masters[1].snapshot.contours.map((c) => c.points.length); - expect(lightCounts).toEqual(boldCounts); + // Both masters should have matching total point counts + const lightTotal = masters[0].snapshot.contours.reduce((s, c) => s + c.points.length, 0); + const boldTotal = masters[1].snapshot.contours.reduce((s, c) => s + c.points.length, 0); + expect(lightTotal).toBe(boldTotal); }); it("non-variable font returns isVariable false", () => { diff --git a/crates/shift-node/src/font_engine.rs b/crates/shift-node/src/font_engine.rs index cc4d7522..0642ba6c 100644 --- a/crates/shift-node/src/font_engine.rs +++ b/crates/shift-node/src/font_engine.rs @@ -4,8 +4,8 @@ use napi_derive::napi; use shift_core::{ composite::{ flatten_component_contours_for_layer as flatten_component_contours, layer_bbox, - layer_complexity, layer_to_svg_path, preferred_layer_for_glyph, - resolve_component_instances_for_layer, resolved_to_render_contours, GlyphLayerProvider, + layer_to_svg_path, resolve_component_instances_for_layer, resolved_to_render_contours, + GlyphLayerProvider, }, dependency_graph::DependencyGraph, edit_session::EditSession, @@ -59,10 +59,8 @@ impl GlyphLayerProvider for EngineLayerProvider<'_> { } } - self - .font - .glyph(glyph_name) - .and_then(preferred_layer_for_glyph) + let glyph = self.font.glyph(glyph_name)?; + glyph.layer(self.font.default_layer_id()) } } @@ -233,6 +231,10 @@ impl FontEngine { } } + fn default_layer_for_glyph<'a>(&'a self, glyph: &'a Glyph) -> Option<&'a GlyphLayer> { + glyph.layer(self.font.default_layer_id()) + } + fn editing_target_for_unicode(&self, unicode: u32) -> Option<(&str, &GlyphLayer)> { if let Some(session) = &self.current_edit_session { if session.unicode() == unicode { @@ -249,7 +251,7 @@ impl FontEngine { } let glyph = self.font.glyph_by_unicode(unicode)?; - let layer = preferred_layer_for_glyph(glyph)?; + let layer = self.default_layer_for_glyph(glyph)?; composite_debug!( "editing_target_for_unicode U+{:04X}: from font glyph='{}' (contours={}, components={}, anchors={})", unicode, @@ -269,7 +271,7 @@ impl FontEngine { } let glyph = self.font.glyph(glyph_name)?; - let layer = preferred_layer_for_glyph(glyph)?; + let layer = self.default_layer_for_glyph(glyph)?; Some((glyph.name(), layer)) } @@ -280,10 +282,8 @@ impl FontEngine { } } - self - .font - .glyph(glyph_name) - .and_then(preferred_layer_for_glyph) + let glyph = self.font.glyph(glyph_name)?; + self.default_layer_for_glyph(glyph) } fn flatten_component_contours_for_layer( @@ -394,7 +394,7 @@ impl FontEngine { } } let glyph = self.font.glyph_by_unicode(unicode)?; - preferred_layer_for_glyph(glyph) + self.default_layer_for_glyph(glyph) } #[napi] @@ -637,19 +637,16 @@ impl FontEngine { glyph.layers().len(), primary_unicode ); - let (layer_id, layer) = glyph - .layers() - .iter() - .max_by_key(|(_, l)| layer_complexity(l)) - .map(|(id, _)| *id) - .and_then(|id| glyph.remove_layer(id).map(|l| (id, l))) - .unwrap_or_else(|| (self.font.default_layer_id(), GlyphLayer::with_width(500.0))); + let default_layer_id = self.font.default_layer_id(); + let layer = glyph + .remove_layer(default_layer_id) + .unwrap_or_else(|| GlyphLayer::with_width(500.0)); let edit_session = EditSession::new(glyph.name().to_string(), primary_unicode, layer); self.current_edit_session = Some(edit_session); self.editing_glyph = Some(glyph); - self.editing_layer_id = Some(layer_id); + self.editing_layer_id = Some(default_layer_id); Ok(()) } From e4f9b0542d748fbdc56f8ace60ddaa2e9e3bf0cd Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 18:57:30 +0100 Subject: [PATCH 06/41] Fix interpolation: skip empty support layer masters, add debug logging Support layer sources (e.g. crossbar, S.wide) often have empty placeholder glyphs with 0 contours. Including these in master snapshots caused checkCompatibility to fail silently, so interpolateGlyph returned null. Skip layers with no contours in getGlyphMasterSnapshots. Add console.warn for compatibility/interpolation failures to aid debugging. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/VariationPanel.tsx | 24 ++++++++++++++++--- crates/shift-node/src/font_engine.rs | 4 ++-- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/VariationPanel.tsx b/apps/desktop/src/renderer/src/components/VariationPanel.tsx index a22300ed..0c8a19a6 100644 --- a/apps/desktop/src/renderer/src/components/VariationPanel.tsx +++ b/apps/desktop/src/renderer/src/components/VariationPanel.tsx @@ -3,7 +3,11 @@ import type { Axis } from "@shift/types"; import { SidebarSection } from "./sidebar-right/SidebarSection"; import { getEditor } from "@/store/store"; import { useSignalState } from "@/lib/reactive"; -import { interpolateGlyph, type MasterSnapshot } from "@/lib/interpolation/interpolate"; +import { + checkCompatibility, + interpolateGlyph, + type MasterSnapshot, +} from "@/lib/interpolation/interpolate"; /** Variation axis slider panel — shown when a variable font is loaded. */ export const VariationPanel = () => { @@ -46,7 +50,14 @@ export const VariationPanel = () => { return; } - mastersRef.current = engine.getGlyphMasterSnapshots(editingGlyph); + const loaded = engine.getGlyphMasterSnapshots(editingGlyph); + mastersRef.current = loaded; + if (loaded) { + const err = checkCompatibility(loaded); + if (err) { + console.warn(`[VariationPanel] incompatible masters for '${editingGlyph}':`, err); + } + } setIsInterpolating(false); }, [axes, editingGlyph, engine]); @@ -59,7 +70,14 @@ export const VariationPanel = () => { if (!masters || masters.length < 2) return; const result = interpolateGlyph(masters, axes, newLocation); - if (!result) return; + if (!result) { + console.warn("[VariationPanel] interpolation returned null", { + masters: masters.length, + axes: axes.length, + newLocation, + }); + return; + } setIsInterpolating(true); engine.emitGlyph(result); diff --git a/crates/shift-node/src/font_engine.rs b/crates/shift-node/src/font_engine.rs index 0642ba6c..2e822418 100644 --- a/crates/shift-node/src/font_engine.rs +++ b/crates/shift-node/src/font_engine.rs @@ -560,8 +560,8 @@ impl FontEngine { for source in self.font.sources() { let layer_id = source.layer_id(); let layer = match glyph.layer(layer_id) { - Some(l) => l, - None => continue, + Some(l) if !l.contours().is_empty() => l, + _ => continue, }; let primary_unicode = glyph.primary_unicode().unwrap_or(0); From 2670a32ef6eec867b8d2229cd2aa146a137ee840 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 19:07:17 +0100 Subject: [PATCH 07/41] Replace inverse-distance interpolation with OpenType VariationModel Port the fonttools/Fontra VariationModel algorithm: support region box-splitting, delta decomposition, and multilinear tent-function scalars. This produces correct interpolation for multi-axis fonts like MutatorSans (2 axes, 4+ masters). Remove console.warn logging. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/VariationPanel.tsx | 24 +- .../src/lib/interpolation/interpolate.test.ts | 96 ++-- .../src/lib/interpolation/interpolate.ts | 418 +++++++++++++----- 3 files changed, 376 insertions(+), 162 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/VariationPanel.tsx b/apps/desktop/src/renderer/src/components/VariationPanel.tsx index 0c8a19a6..a22300ed 100644 --- a/apps/desktop/src/renderer/src/components/VariationPanel.tsx +++ b/apps/desktop/src/renderer/src/components/VariationPanel.tsx @@ -3,11 +3,7 @@ import type { Axis } from "@shift/types"; import { SidebarSection } from "./sidebar-right/SidebarSection"; import { getEditor } from "@/store/store"; import { useSignalState } from "@/lib/reactive"; -import { - checkCompatibility, - interpolateGlyph, - type MasterSnapshot, -} from "@/lib/interpolation/interpolate"; +import { interpolateGlyph, type MasterSnapshot } from "@/lib/interpolation/interpolate"; /** Variation axis slider panel — shown when a variable font is loaded. */ export const VariationPanel = () => { @@ -50,14 +46,7 @@ export const VariationPanel = () => { return; } - const loaded = engine.getGlyphMasterSnapshots(editingGlyph); - mastersRef.current = loaded; - if (loaded) { - const err = checkCompatibility(loaded); - if (err) { - console.warn(`[VariationPanel] incompatible masters for '${editingGlyph}':`, err); - } - } + mastersRef.current = engine.getGlyphMasterSnapshots(editingGlyph); setIsInterpolating(false); }, [axes, editingGlyph, engine]); @@ -70,14 +59,7 @@ export const VariationPanel = () => { if (!masters || masters.length < 2) return; const result = interpolateGlyph(masters, axes, newLocation); - if (!result) { - console.warn("[VariationPanel] interpolation returned null", { - masters: masters.length, - axes: axes.length, - newLocation, - }); - return; - } + if (!result) return; setIsInterpolating(true); engine.emitGlyph(result); diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts index 83c0c0af..4df129d3 100644 --- a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts @@ -11,9 +11,9 @@ function makeAxis(overrides?: Partial): Axis { return { tag: "wght", name: "Weight", - minimum: 100, - default: 400, - maximum: 900, + minimum: 0, + default: 0, + maximum: 1000, hidden: false, ...overrides, }; @@ -47,20 +47,20 @@ function makeSnapshot(contours: ContourSnapshot[], xAdvance: number): GlyphSnaps function makeMaster( name: string, - wght: number, + locationValues: Record, contour: ContourSnapshot, xAdvance: number, ): MasterSnapshot { return { sourceId: name, sourceName: name, - location: { values: { wght } }, + location: { values: locationValues }, snapshot: makeSnapshot([contour], xAdvance), }; } describe("normalizeAxisValue", () => { - const axis = makeAxis(); + const axis = makeAxis({ minimum: 100, default: 400, maximum: 900 }); it("returns 0 at default", () => { expect(normalizeAxisValue(400, axis)).toBe(0); @@ -83,7 +83,7 @@ describe("checkCompatibility", () => { it("returns null for compatible masters", () => { const light = makeMaster( "Light", - 100, + { wght: 0 }, makeContour([ { x: 0, y: 0 }, { x: 100, y: 0 }, @@ -92,7 +92,7 @@ describe("checkCompatibility", () => { ); const bold = makeMaster( "Bold", - 900, + { wght: 1000 }, makeContour([ { x: 10, y: 0 }, { x: 110, y: 0 }, @@ -103,19 +103,19 @@ describe("checkCompatibility", () => { }); it("reports contour count mismatch", () => { - const light = makeMaster("Light", 100, makeContour([{ x: 0, y: 0 }]), 500); + const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 0, y: 0 }]), 500); const bold: MasterSnapshot = { - ...makeMaster("Bold", 900, makeContour([{ x: 0, y: 0 }]), 600), + ...makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 0, y: 0 }]), 600), snapshot: makeSnapshot([makeContour([{ x: 0, y: 0 }]), makeContour([{ x: 50, y: 50 }])], 600), }; expect(checkCompatibility([light, bold])).toContain("2 contours"); }); it("reports point count mismatch", () => { - const light = makeMaster("Light", 100, makeContour([{ x: 0, y: 0 }]), 500); + const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 0, y: 0 }]), 500); const bold = makeMaster( "Bold", - 900, + { wght: 1000 }, makeContour([ { x: 0, y: 0 }, { x: 100, y: 0 }, @@ -130,14 +130,14 @@ describe("interpolateGlyph", () => { const axes = [makeAxis()]; it("returns the single master's snapshot when only one master", () => { - const master = makeMaster("Regular", 400, makeContour([{ x: 100, y: 200 }]), 500); - const result = interpolateGlyph([master], axes, { wght: 400 }); + const master = makeMaster("Regular", { wght: 0 }, makeContour([{ x: 100, y: 200 }]), 500); + const result = interpolateGlyph([master], axes, { wght: 0 }); expect(result).toBe(master.snapshot); }); it("interpolates at midpoint between two masters", () => { - const light = makeMaster("Light", 100, makeContour([{ x: 100, y: 200 }]), 500); - const bold = makeMaster("Bold", 900, makeContour([{ x: 200, y: 400 }]), 700); + const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 100, y: 200 }]), 500); + const bold = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 200, y: 400 }]), 700); const result = interpolateGlyph([light, bold], axes, { wght: 500 }); @@ -147,31 +147,31 @@ describe("interpolateGlyph", () => { expect(result!.xAdvance).toBeCloseTo(600); }); - it("returns master A at master A location", () => { - const light = makeMaster("Light", 100, makeContour([{ x: 100, y: 200 }]), 500); - const bold = makeMaster("Bold", 900, makeContour([{ x: 200, y: 400 }]), 700); + it("returns default master at default location", () => { + const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 100, y: 200 }]), 500); + const bold = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 200, y: 400 }]), 700); - const result = interpolateGlyph([light, bold], axes, { wght: 100 }); + const result = interpolateGlyph([light, bold], axes, { wght: 0 }); expect(result!.contours[0].points[0].x).toBeCloseTo(100); expect(result!.contours[0].points[0].y).toBeCloseTo(200); }); - it("returns master B at master B location", () => { - const light = makeMaster("Light", 100, makeContour([{ x: 100, y: 200 }]), 500); - const bold = makeMaster("Bold", 900, makeContour([{ x: 200, y: 400 }]), 700); + it("returns non-default master at its location", () => { + const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 100, y: 200 }]), 500); + const bold = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 200, y: 400 }]), 700); - const result = interpolateGlyph([light, bold], axes, { wght: 900 }); + const result = interpolateGlyph([light, bold], axes, { wght: 1000 }); expect(result!.contours[0].points[0].x).toBeCloseTo(200); expect(result!.contours[0].points[0].y).toBeCloseTo(400); }); it("returns null for incompatible masters", () => { - const light = makeMaster("Light", 100, makeContour([{ x: 0, y: 0 }]), 500); + const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 0, y: 0 }]), 500); const bold = makeMaster( "Bold", - 900, + { wght: 1000 }, makeContour([ { x: 0, y: 0 }, { x: 100, y: 0 }, @@ -184,8 +184,8 @@ describe("interpolateGlyph", () => { }); it("preserves point metadata from reference master", () => { - const light = makeMaster("Light", 100, makeContour([{ x: 100, y: 200 }]), 500); - const bold = makeMaster("Bold", 900, makeContour([{ x: 200, y: 400 }]), 700); + const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 100, y: 200 }]), 500); + const bold = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 200, y: 400 }]), 700); const result = interpolateGlyph([light, bold], axes, { wght: 500 }); @@ -193,4 +193,44 @@ describe("interpolateGlyph", () => { expect(result!.contours[0].points[0].pointType).toBe("onCurve"); expect(result!.contours[0].id).toBe(light.snapshot.contours[0].id); }); + + it("interpolates with 4 masters on 2 axes", () => { + const wdthAxis = makeAxis({ tag: "wdth", name: "Width" }); + const wghtAxis = makeAxis({ tag: "wght", name: "Weight" }); + + const lc = makeMaster( + "LightCondensed", + { wdth: 0, wght: 0 }, + makeContour([{ x: 0, y: 0 }]), + 400, + ); + const bc = makeMaster( + "BoldCondensed", + { wdth: 0, wght: 1000 }, + makeContour([{ x: 100, y: 0 }]), + 500, + ); + const lw = makeMaster( + "LightWide", + { wdth: 1000, wght: 0 }, + makeContour([{ x: 0, y: 100 }]), + 600, + ); + const bw = makeMaster( + "BoldWide", + { wdth: 1000, wght: 1000 }, + makeContour([{ x: 100, y: 100 }]), + 700, + ); + + const result = interpolateGlyph([lc, bc, lw, bw], [wdthAxis, wghtAxis], { + wdth: 500, + wght: 500, + }); + + expect(result).not.toBeNull(); + expect(result!.contours[0].points[0].x).toBeCloseTo(50); + expect(result!.contours[0].points[0].y).toBeCloseTo(50); + expect(result!.xAdvance).toBeCloseTo(550); + }); }); diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts index 66c9c944..ecf49aca 100644 --- a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts @@ -1,3 +1,10 @@ +/** + * Glyph interpolation engine using the OpenType VariationModel algorithm. + * + * Ported from Fontra's var-model.js which is itself a port of + * fontTools.varLib.models.VariationModel. Uses support-region box-splitting + * and delta decomposition for correct multilinear interpolation. + */ import type { Axis, GlyphSnapshot, ContourSnapshot, PointSnapshot } from "@shift/types"; /** A single master's glyph data with its design-space location. */ @@ -8,7 +15,8 @@ export interface MasterSnapshot { snapshot: GlyphSnapshot; } -/** Normalize an axis value to the range [-1, 1]. */ +// ── Axis normalization ────────────────────────────────────────────── + export function normalizeAxisValue(value: number, axis: Axis): number { if (value < axis.default) { const range = axis.default - axis.minimum; @@ -23,10 +31,26 @@ export function normalizeAxisValue(value: number, axis: Axis): number { return 0; } -/** - * Check that all masters have compatible contour structure. - * Returns null if compatible, or a description of the first incompatibility. - */ +type SparseLocation = Record; +type Support = Record; + +function normalizeLocation( + location: Record, + axes: Axis[], +): SparseLocation { + const out: SparseLocation = {}; + for (const axis of axes) { + const v = location[axis.tag] ?? axis.default; + const n = normalizeAxisValue(v, axis); + if (Math.abs(n) > 1e-14) { + out[axis.tag] = n; + } + } + return out; +} + +// ── Compatibility check ───────────────────────────────────────────── + export function checkCompatibility(masters: MasterSnapshot[]): string | null { if (masters.length < 2) return null; @@ -40,11 +64,8 @@ export function checkCompatibility(masters: MasterSnapshot[]): string | null { } for (let c = 0; c < ref.contours.length; c++) { - const refContour = ref.contours[c]; - const otherContour = other.contours[c]; - - if (refContour.points.length !== otherContour.points.length) { - return `Master "${masters[m].sourceName}" contour ${c}: ${otherContour.points.length} points, expected ${refContour.points.length}`; + if (ref.contours[c].points.length !== other.contours[c].points.length) { + return `Master "${masters[m].sourceName}" contour ${c}: ${other.contours[c].points.length} points, expected ${ref.contours[c].points.length}`; } } } @@ -52,140 +73,311 @@ export function checkCompatibility(masters: MasterSnapshot[]): string | null { return null; } -/** - * Interpolate a glyph at a target design-space location. - * - * For 2 masters on 1 axis this is simple linear interpolation: - * result = masterA * (1 - t) + masterB * t - * - * For N masters on M axes we compute per-master scalar weights - * using bilinear/multilinear interpolation, then blend. - */ -export function interpolateGlyph( - masters: MasterSnapshot[], - axes: Axis[], - target: Record, -): GlyphSnapshot | null { - if (masters.length === 0) return null; - if (masters.length === 1) return masters[0].snapshot; +// ── OpenType VariationModel (ported from Fontra/fonttools) ────────── - const error = checkCompatibility(masters); - if (error) return null; +function supportScalar(location: SparseLocation, support: Support): number { + let scalar = 1.0; + for (const axis in support) { + const [lower, peak, upper] = support[axis]; + if (peak === 0.0) continue; + if (lower > peak || peak > upper) continue; + if (lower < 0.0 && upper > 0.0) continue; - const weights = computeMasterWeights(masters, axes, target); + const v = location[axis] ?? 0.0; + if (v === peak) continue; + if (v <= lower || upper <= v) return 0.0; + if (v < peak) { + scalar *= (v - lower) / (peak - lower); + } else { + scalar *= (v - upper) / (peak - upper); + } + } + return scalar; +} - const ref = masters[0].snapshot; +function locationsToRegions(locations: SparseLocation[]): Support[] { + const minV: Record = {}; + const maxV: Record = {}; + for (const loc of locations) { + for (const [k, v] of Object.entries(loc)) { + minV[k] = Math.min(v, minV[k] ?? v); + maxV[k] = Math.max(v, maxV[k] ?? v); + } + } - const contours: ContourSnapshot[] = ref.contours.map((refContour, ci) => ({ - id: refContour.id, - closed: refContour.closed, - points: refContour.points.map((refPoint, pi) => - blendPoint( - masters.map((m) => m.snapshot.contours[ci].points[pi]), - weights, - refPoint, - ), - ), - })); + return locations.map((loc) => { + const region: Support = {}; + for (const [axis, locV] of Object.entries(loc)) { + region[axis] = locV > 0 ? [0, locV, maxV[axis]] : [minV[axis], locV, 0]; + } + return region; + }); +} + +function locationToString(loc: SparseLocation): string { + const sorted: SparseLocation = {}; + for (const key of Object.keys(loc).sort()) { + sorted[key] = loc[key]; + } + return JSON.stringify(sorted); +} + +function isSuperset(set: Set, keys: string[]): boolean { + for (const k of keys) { + if (!set.has(k)) return false; + } + return true; +} - let xAdvance = 0; - for (let i = 0; i < masters.length; i++) { - xAdvance += masters[i].snapshot.xAdvance * weights[i]; +function deepCompare(a: unknown[], b: unknown[]): number { + const length = Math.max(a.length, b.length); + for (let i = 0; i < length; i++) { + const itemA = a[i]; + const itemB = b[i]; + if (itemA === undefined) return -1; + if (itemB === undefined) return 1; + if (Array.isArray(itemA) && Array.isArray(itemB)) { + const r = deepCompare(itemA, itemB); + if (r !== 0) return r; + } else if (typeof itemA === "number" && typeof itemB === "number") { + if (itemA < itemB) return -1; + if (itemA > itemB) return 1; + } else if (typeof itemA === "string" && typeof itemB === "string") { + if (itemA < itemB) return -1; + if (itemA > itemB) return 1; + } } + return 0; +} - return { - ...ref, - xAdvance, - contours, - }; +interface VariationModelData { + mapping: number[]; + reverseMapping: number[]; + supports: Support[]; + deltaWeights: Map[]; } -function blendPoint(points: PointSnapshot[], weights: number[], ref: PointSnapshot): PointSnapshot { - let x = 0; - let y = 0; +function buildVariationModel( + masterLocations: SparseLocation[], + axisOrder: string[], +): VariationModelData { + // Sort locations using fonttools' decorated sort + const axisPoints: Record> = {}; + for (const loc of masterLocations) { + const keys = Object.keys(loc); + if (keys.length !== 1) continue; + const axis = keys[0]; + if (!axisPoints[axis]) axisPoints[axis] = new Set([0.0]); + axisPoints[axis].add(loc[axis]); + } + + const decorated: [unknown[], SparseLocation, number][] = masterLocations.map((loc, i) => { + const entries = Object.entries(loc); + const rank = entries.length; + const onPointAxes: string[] = []; + for (const [axis, value] of entries) { + if (axisPoints[axis]?.has(value)) onPointAxes.push(axis); + } + const orderedAxes = [ + ...axisOrder.filter((a) => loc[a] !== undefined), + ...Object.keys(loc) + .sort() + .filter((a) => !axisOrder.includes(a)), + ]; + const deco: unknown[] = [ + rank, + -onPointAxes.length, + orderedAxes.map((a) => { + const idx = axisOrder.indexOf(a); + return idx !== -1 ? idx : 0x10000; + }), + orderedAxes, + orderedAxes.map((a) => Math.sign(loc[a])), + orderedAxes.map((a) => Math.abs(loc[a])), + ]; + return [deco, loc, i]; + }); + + decorated.sort((a, b) => deepCompare(a[0] as unknown[], b[0] as unknown[])); + const sortedLocations = decorated.map((d) => d[1]); + + const locStrings = masterLocations.map(locationToString); + const sortedStrings = sortedLocations.map(locationToString); + const mapping = locStrings.map((s) => sortedStrings.indexOf(s)); + const reverseMapping = sortedStrings.map((s) => locStrings.indexOf(s)); + + // Compute supports via box-splitting + const regions = locationsToRegions(sortedLocations); + const supports: Support[] = []; + + for (let i = 0; i < regions.length; i++) { + const region = { ...regions[i] }; + // Deep-copy the region's arrays + for (const axis in region) { + region[axis] = [...region[axis]]; + } + const locAxes = new Set(Object.keys(region)); + + for (let j = 0; j < i; j++) { + const prevRegion = supports[j]; + if (!isSuperset(locAxes, Object.keys(prevRegion))) continue; + + let relevant = true; + for (const [axis, [lower, , upper]] of Object.entries(region)) { + const prev = prevRegion[axis]; + if (!prev || !(prev[1] === region[axis][1] || (lower < prev[1] && prev[1] < upper))) { + relevant = false; + break; + } + } + if (!relevant) continue; + + // Split box + let bestAxes: Record = {}; + let bestRatio = -1; + for (const axis of Object.keys(prevRegion)) { + const val = prevRegion[axis][1]; + const [lower, locV, upper] = region[axis]; + let ratio: number; + if (val < locV) { + ratio = (val - locV) / (lower - locV); + if (ratio > bestRatio) { + bestAxes = {}; + bestRatio = ratio; + } + if (ratio === bestRatio) bestAxes[axis] = [val, locV, upper]; + } else if (locV < val) { + ratio = (val - locV) / (upper - locV); + if (ratio > bestRatio) { + bestAxes = {}; + bestRatio = ratio; + } + if (ratio === bestRatio) bestAxes[axis] = [lower, locV, val]; + } + } + for (const axis in bestAxes) { + region[axis] = bestAxes[axis]; + } + } + supports.push(region); + } - for (let i = 0; i < points.length; i++) { - x += points[i].x * weights[i]; - y += points[i].y * weights[i]; + // Compute delta weights + const deltaWeights: Map[] = []; + for (let i = 0; i < sortedLocations.length; i++) { + const loc = sortedLocations[i]; + const dw = new Map(); + for (let j = 0; j < i; j++) { + const scalar = supportScalar(loc, supports[j]); + if (scalar) dw.set(j, scalar); + } + deltaWeights.push(dw); } - return { ...ref, x, y }; + return { mapping, reverseMapping, supports, deltaWeights }; } -/** - * Compute scalar weights for each master given a target location. - * - * Uses bilinear/multilinear interpolation in normalized design space. - * For the common 2-master / 1-axis case this reduces to simple lerp. - */ -function computeMasterWeights( - masters: MasterSnapshot[], - axes: Axis[], - target: Record, -): number[] { - if (masters.length === 2 && axes.length === 1) { - return twoMasterWeights(masters, axes[0], target); +// ── Flat array helpers for point data ─────────────────────────────── + +/** Flatten a snapshot's point coordinates + xAdvance into a single number[]. */ +function snapshotToFlat(snapshot: GlyphSnapshot): number[] { + const flat: number[] = [snapshot.xAdvance]; + for (const contour of snapshot.contours) { + for (const pt of contour.points) { + flat.push(pt.x, pt.y); + } } + return flat; +} - return generalWeights(masters, axes, target); +function flatSub(a: number[], b: number[]): number[] { + const r: number[] = Array.from({ length: a.length }); + for (let i = 0; i < a.length; i++) r[i] = a[i] - b[i]; + return r; } -/** Fast path for the common 2-master / 1-axis case. */ -function twoMasterWeights( - masters: MasterSnapshot[], - axis: Axis, - target: Record, -): number[] { - const tag = axis.tag; - const targetVal = target[tag] ?? axis.default; +function flatMulScalar(a: number[], s: number): number[] { + const r: number[] = Array.from({ length: a.length }); + for (let i = 0; i < a.length; i++) r[i] = a[i] * s; + return r; +} + +function flatAdd(a: number[], b: number[]): number[] { + const r: number[] = Array.from({ length: a.length }); + for (let i = 0; i < a.length; i++) r[i] = a[i] + b[i]; + return r; +} - const valA = masters[0].location.values[tag] ?? axis.default; - const valB = masters[1].location.values[tag] ?? axis.default; +/** Reconstruct a GlyphSnapshot from a flat array, using a reference for structure. */ +function flatToSnapshot(flat: number[], ref: GlyphSnapshot): GlyphSnapshot { + let idx = 0; + const xAdvance = flat[idx++]; - const range = valB - valA; - if (Math.abs(range) < Number.EPSILON) return [0.5, 0.5]; + const contours: ContourSnapshot[] = ref.contours.map((refContour) => ({ + id: refContour.id, + closed: refContour.closed, + points: refContour.points.map((refPt) => { + const x = flat[idx++]; + const y = flat[idx++]; + return { ...refPt, x, y } as PointSnapshot; + }), + })); - const t = Math.max(0, Math.min(1, (targetVal - valA) / range)); - return [1 - t, t]; + return { ...ref, xAdvance, contours }; } +// ── Public API ────────────────────────────────────────────────────── + /** - * General N-master / M-axis interpolation using inverse-distance weighting - * in normalized design space. This is a reasonable approximation for - * arbitrary master configurations. + * Interpolate a glyph at a target design-space location using the + * OpenType VariationModel algorithm (support regions + delta decomposition). */ -function generalWeights( +export function interpolateGlyph( masters: MasterSnapshot[], axes: Axis[], target: Record, -): number[] { - const normalizedTarget: Record = {}; - for (const axis of axes) { - normalizedTarget[axis.tag] = normalizeAxisValue(target[axis.tag] ?? axis.default, axis); - } +): GlyphSnapshot | null { + if (masters.length === 0) return null; + if (masters.length === 1) return masters[0].snapshot; - const distances: number[] = masters.map((master) => { - let dist = 0; - for (const axis of axes) { - const masterVal = master.location.values[axis.tag] ?? axis.default; - const nMaster = normalizeAxisValue(masterVal, axis); - const diff = normalizedTarget[axis.tag] - nMaster; - dist += diff * diff; - } - return Math.sqrt(dist); - }); + const error = checkCompatibility(masters); + if (error) return null; + + // Normalize master locations (sparse: omit axes at default) + const normalizedLocations = masters.map((m) => normalizeLocation(m.location.values, axes)); + const axisOrder = axes.map((a) => a.tag); - // Check for exact match - for (let i = 0; i < distances.length; i++) { - if (distances[i] < 1e-10) { - const weights = Array.from({ length: masters.length }, () => 0); - weights[i] = 1; - return weights; + // Build model + const model = buildVariationModel(normalizedLocations, axisOrder); + + // Flatten master values into number arrays + const masterFlats = masters.map((m) => snapshotToFlat(m.snapshot)); + + // Compute deltas + const deltas: number[][] = []; + for (let i = 0; i < masterFlats.length; i++) { + let delta = masterFlats[model.reverseMapping[i]]; + const weights = model.deltaWeights[i]; + for (const [j, weight] of weights.entries()) { + delta = + weight === 1 ? flatSub(delta, deltas[j]) : flatSub(delta, flatMulScalar(deltas[j], weight)); } + deltas.push(delta); + } + + // Compute scalars at target location + const normalizedTarget = normalizeLocation(target, axes); + const scalars = model.supports.map((support) => supportScalar(normalizedTarget, support)); + + // Interpolate: sum delta[i] * scalar[i] + let result: number[] | null = null; + for (let i = 0; i < scalars.length; i++) { + if (!scalars[i]) continue; + const contribution = flatMulScalar(deltas[i], scalars[i]); + result = result === null ? contribution : flatAdd(result, contribution); } - // Inverse distance weighting - const invDist = distances.map((d) => 1 / d); - const sum = invDist.reduce((a, b) => a + b, 0); - return invDist.map((w) => w / sum); + if (!result) return masters[0].snapshot; + + return flatToSnapshot(result, masters[0].snapshot); } From 8f0c00c90b4e4969ebd852f83280defa561fb7ac Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 19:14:14 +0100 Subject: [PATCH 08/41] temp: add debug logging to VariationPanel --- .../src/renderer/src/components/VariationPanel.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/VariationPanel.tsx b/apps/desktop/src/renderer/src/components/VariationPanel.tsx index a22300ed..da277be6 100644 --- a/apps/desktop/src/renderer/src/components/VariationPanel.tsx +++ b/apps/desktop/src/renderer/src/components/VariationPanel.tsx @@ -37,16 +37,20 @@ export const VariationPanel = () => { // Track the current glyph and reload masters when it changes useEffect(() => { const glyphName = engine.getEditingGlyphName(); + console.log("[Variation] editingGlyph:", glyphName); setEditingGlyph(glyphName); }); useEffect(() => { if (axes.length === 0 || !editingGlyph) { + console.log("[Variation] skipping masters load: axes=", axes.length, "glyph=", editingGlyph); mastersRef.current = null; return; } - mastersRef.current = engine.getGlyphMasterSnapshots(editingGlyph); + const masters = engine.getGlyphMasterSnapshots(editingGlyph); + console.log("[Variation] loaded masters:", masters?.length, "for", editingGlyph); + mastersRef.current = masters; setIsInterpolating(false); }, [axes, editingGlyph, engine]); @@ -59,8 +63,10 @@ export const VariationPanel = () => { if (!masters || masters.length < 2) return; const result = interpolateGlyph(masters, axes, newLocation); + console.log("[Variation] interpolation result:", result ? "OK" : "null", "masters:", masters.length); if (!result) return; + console.log("[Variation] emitting glyph, xAdvance:", result.xAdvance); setIsInterpolating(true); engine.emitGlyph(result); }, @@ -81,7 +87,7 @@ export const VariationPanel = () => { } setLocation(newLocation); - // Show this master's snapshot directly + console.log("[Variation] master click:", sourceName, "emitting snapshot"); setIsInterpolating(true); engine.emitGlyph(master.snapshot); }, From 6a15a7dc076b87ca8540f09d618fc698785e1038 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 19:16:49 +0100 Subject: [PATCH 09/41] Fix getGlyphMasterSnapshots for editing glyph The editing glyph is removed from the font during an active session (via take_glyph) and its default layer moved into the EditSession. Reconstruct the full glyph with all layers before building master snapshots so interpolation works for the currently open glyph. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/VariationPanel.tsx | 12 ++--------- crates/shift-node/src/font_engine.rs | 21 +++++++++++++++++-- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/VariationPanel.tsx b/apps/desktop/src/renderer/src/components/VariationPanel.tsx index da277be6..01b12e5e 100644 --- a/apps/desktop/src/renderer/src/components/VariationPanel.tsx +++ b/apps/desktop/src/renderer/src/components/VariationPanel.tsx @@ -36,21 +36,16 @@ export const VariationPanel = () => { // Track the current glyph and reload masters when it changes useEffect(() => { - const glyphName = engine.getEditingGlyphName(); - console.log("[Variation] editingGlyph:", glyphName); - setEditingGlyph(glyphName); + setEditingGlyph(engine.getEditingGlyphName()); }); useEffect(() => { if (axes.length === 0 || !editingGlyph) { - console.log("[Variation] skipping masters load: axes=", axes.length, "glyph=", editingGlyph); mastersRef.current = null; return; } - const masters = engine.getGlyphMasterSnapshots(editingGlyph); - console.log("[Variation] loaded masters:", masters?.length, "for", editingGlyph); - mastersRef.current = masters; + mastersRef.current = engine.getGlyphMasterSnapshots(editingGlyph); setIsInterpolating(false); }, [axes, editingGlyph, engine]); @@ -63,10 +58,8 @@ export const VariationPanel = () => { if (!masters || masters.length < 2) return; const result = interpolateGlyph(masters, axes, newLocation); - console.log("[Variation] interpolation result:", result ? "OK" : "null", "masters:", masters.length); if (!result) return; - console.log("[Variation] emitting glyph, xAdvance:", result.xAdvance); setIsInterpolating(true); engine.emitGlyph(result); }, @@ -87,7 +80,6 @@ export const VariationPanel = () => { } setLocation(newLocation); - console.log("[Variation] master click:", sourceName, "emitting snapshot"); setIsInterpolating(true); engine.emitGlyph(master.snapshot); }, diff --git a/crates/shift-node/src/font_engine.rs b/crates/shift-node/src/font_engine.rs index 2e822418..02b435a0 100644 --- a/crates/shift-node/src/font_engine.rs +++ b/crates/shift-node/src/font_engine.rs @@ -540,12 +540,29 @@ impl FontEngine { /// including the source location. Used by the TS interpolation engine. #[napi] pub fn get_glyph_master_snapshots(&self, glyph_name: String) -> Option { - let glyph = self.font.glyph(&glyph_name)?; - if !self.font.is_variable() { return None; } + // The editing glyph is taken out of the font during a session + // (and its default layer moved into the EditSession), so reconstruct it. + let mut temp_glyph; + let glyph = if let (Some(editing), Some(session), Some(layer_id)) = ( + &self.editing_glyph, + &self.current_edit_session, + &self.editing_layer_id, + ) { + if editing.name() == glyph_name { + temp_glyph = editing.clone(); + temp_glyph.set_layer(*layer_id, session.layer().clone()); + &temp_glyph + } else { + self.font.glyph(&glyph_name)? + } + } else { + self.font.glyph(&glyph_name)? + }; + #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] struct MasterSnapshot { From 8af447391b437e54b8e3c9ff5a1af260df79b935 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 19:20:37 +0100 Subject: [PATCH 10/41] Handle duplicate locations and errors in interpolation Deduplicate master locations before building the VariationModel to avoid crashes from designspace files with overlapping sources. Move compatibility check after dedup. Wrap model construction in try/catch for robustness with edge-case source configurations. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/interpolation/interpolate.ts | 37 +++++++++++++++---- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts index ecf49aca..07d319f2 100644 --- a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts @@ -340,18 +340,39 @@ export function interpolateGlyph( if (masters.length === 0) return null; if (masters.length === 1) return masters[0].snapshot; - const error = checkCompatibility(masters); - if (error) return null; - // Normalize master locations (sparse: omit axes at default) const normalizedLocations = masters.map((m) => normalizeLocation(m.location.values, axes)); + + // Deduplicate locations — keep only the first master at each location + const seen = new Set(); + const uniqueIndices: number[] = []; + for (let i = 0; i < normalizedLocations.length; i++) { + const key = locationToString(normalizedLocations[i]); + if (!seen.has(key)) { + seen.add(key); + uniqueIndices.push(i); + } + } + const uniqueMasters = uniqueIndices.map((i) => masters[i]); + const uniqueLocations = uniqueIndices.map((i) => normalizedLocations[i]); + + if (uniqueMasters.length < 2) return uniqueMasters[0]?.snapshot ?? null; + + const error = checkCompatibility(uniqueMasters); + if (error) return null; + const axisOrder = axes.map((a) => a.tag); - // Build model - const model = buildVariationModel(normalizedLocations, axisOrder); + // Build model — wrap in try/catch for robustness + let model: VariationModelData; + try { + model = buildVariationModel(uniqueLocations, axisOrder); + } catch { + return null; + } // Flatten master values into number arrays - const masterFlats = masters.map((m) => snapshotToFlat(m.snapshot)); + const masterFlats = uniqueMasters.map((m) => snapshotToFlat(m.snapshot)); // Compute deltas const deltas: number[][] = []; @@ -377,7 +398,7 @@ export function interpolateGlyph( result = result === null ? contribution : flatAdd(result, contribution); } - if (!result) return masters[0].snapshot; + if (!result) return uniqueMasters[0].snapshot; - return flatToSnapshot(result, masters[0].snapshot); + return flatToSnapshot(result, uniqueMasters[0].snapshot); } From 5e259249f61f0920b01cb6b15ddf513cbb994130 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 19:22:24 +0100 Subject: [PATCH 11/41] Fix duplicate React keys in master buttons Use sourceId instead of sourceName as the React key to handle designspace files with duplicate source names (e.g. Sans+Slab). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/renderer/src/components/VariationPanel.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/VariationPanel.tsx b/apps/desktop/src/renderer/src/components/VariationPanel.tsx index 01b12e5e..366ff59b 100644 --- a/apps/desktop/src/renderer/src/components/VariationPanel.tsx +++ b/apps/desktop/src/renderer/src/components/VariationPanel.tsx @@ -104,7 +104,7 @@ export const VariationPanel = () => { if (axes.length === 0) return null; - const masterNames = mastersRef.current?.map((m) => m.sourceName) ?? []; + const masters = mastersRef.current ?? []; return ( @@ -117,16 +117,16 @@ export const VariationPanel = () => { onChange={(value) => handleAxisChange(axis.tag, value)} /> ))} - {masterNames.length > 0 && ( + {masters.length > 0 && (

From d9becddd38e23c1d2ee1f99763e158d1736ca64c Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 19:24:25 +0100 Subject: [PATCH 12/41] temp: debug slider callback --- .../src/renderer/src/components/VariationPanel.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/VariationPanel.tsx b/apps/desktop/src/renderer/src/components/VariationPanel.tsx index 366ff59b..5694c7df 100644 --- a/apps/desktop/src/renderer/src/components/VariationPanel.tsx +++ b/apps/desktop/src/renderer/src/components/VariationPanel.tsx @@ -54,10 +54,12 @@ export const VariationPanel = () => { const newLocation = { ...location, [tag]: value }; setLocation(newLocation); - const masters = mastersRef.current; - if (!masters || masters.length < 2) return; + const ms = mastersRef.current; + console.log("[V] axis", tag, value, "masters:", ms?.length, "axes:", axes.length); + if (!ms || ms.length < 2) return; - const result = interpolateGlyph(masters, axes, newLocation); + const result = interpolateGlyph(ms, axes, newLocation); + console.log("[V] result:", result ? `ok adv=${result.xAdvance}` : "null"); if (!result) return; setIsInterpolating(true); From f18e2dddbb354c27db298e21cd423258d8f017fa Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 19:25:04 +0100 Subject: [PATCH 13/41] temp: more debug --- .../src/renderer/src/lib/interpolation/interpolate.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts index 07d319f2..b2fbf02c 100644 --- a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts @@ -359,7 +359,10 @@ export function interpolateGlyph( if (uniqueMasters.length < 2) return uniqueMasters[0]?.snapshot ?? null; const error = checkCompatibility(uniqueMasters); - if (error) return null; + if (error) { + console.log("[interp] compat fail:", error); + return null; + } const axisOrder = axes.map((a) => a.tag); @@ -367,7 +370,8 @@ export function interpolateGlyph( let model: VariationModelData; try { model = buildVariationModel(uniqueLocations, axisOrder); - } catch { + } catch (e) { + console.log("[interp] model build fail:", e); return null; } From a65353637364c8133b98c4a11e05cc0b6201ce39 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 19:27:44 +0100 Subject: [PATCH 14/41] Remove debug logging from interpolation and VariationPanel Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/renderer/src/components/VariationPanel.tsx | 2 -- .../src/renderer/src/lib/interpolation/interpolate.ts | 8 ++------ 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/VariationPanel.tsx b/apps/desktop/src/renderer/src/components/VariationPanel.tsx index 5694c7df..4b6225c0 100644 --- a/apps/desktop/src/renderer/src/components/VariationPanel.tsx +++ b/apps/desktop/src/renderer/src/components/VariationPanel.tsx @@ -55,11 +55,9 @@ export const VariationPanel = () => { setLocation(newLocation); const ms = mastersRef.current; - console.log("[V] axis", tag, value, "masters:", ms?.length, "axes:", axes.length); if (!ms || ms.length < 2) return; const result = interpolateGlyph(ms, axes, newLocation); - console.log("[V] result:", result ? `ok adv=${result.xAdvance}` : "null"); if (!result) return; setIsInterpolating(true); diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts index b2fbf02c..07d319f2 100644 --- a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts @@ -359,10 +359,7 @@ export function interpolateGlyph( if (uniqueMasters.length < 2) return uniqueMasters[0]?.snapshot ?? null; const error = checkCompatibility(uniqueMasters); - if (error) { - console.log("[interp] compat fail:", error); - return null; - } + if (error) return null; const axisOrder = axes.map((a) => a.tag); @@ -370,8 +367,7 @@ export function interpolateGlyph( let model: VariationModelData; try { model = buildVariationModel(uniqueLocations, axisOrder); - } catch (e) { - console.log("[interp] model build fail:", e); + } catch { return null; } From 816996f73018c66a7daeb32526208e155d2d8bf7 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 19:29:54 +0100 Subject: [PATCH 15/41] debug: log master contour counts and compat result on slider --- .../renderer/src/components/VariationPanel.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/VariationPanel.tsx b/apps/desktop/src/renderer/src/components/VariationPanel.tsx index 4b6225c0..e00b5857 100644 --- a/apps/desktop/src/renderer/src/components/VariationPanel.tsx +++ b/apps/desktop/src/renderer/src/components/VariationPanel.tsx @@ -3,7 +3,7 @@ import type { Axis } from "@shift/types"; import { SidebarSection } from "./sidebar-right/SidebarSection"; import { getEditor } from "@/store/store"; import { useSignalState } from "@/lib/reactive"; -import { interpolateGlyph, type MasterSnapshot } from "@/lib/interpolation/interpolate"; +import { checkCompatibility, interpolateGlyph, type MasterSnapshot } from "@/lib/interpolation/interpolate"; /** Variation axis slider panel — shown when a variable font is loaded. */ export const VariationPanel = () => { @@ -45,7 +45,13 @@ export const VariationPanel = () => { return; } - mastersRef.current = engine.getGlyphMasterSnapshots(editingGlyph); + const loaded = engine.getGlyphMasterSnapshots(editingGlyph); + mastersRef.current = loaded; + if (loaded) { + console.log("[V] masters loaded for", editingGlyph, loaded.map(m => + `${m.sourceName}:${m.snapshot.contours.length}c` + )); + } setIsInterpolating(false); }, [axes, editingGlyph, engine]); @@ -58,7 +64,11 @@ export const VariationPanel = () => { if (!ms || ms.length < 2) return; const result = interpolateGlyph(ms, axes, newLocation); - if (!result) return; + if (!result) { + console.log("[V] interp null, compat:", checkCompatibility(ms)); + return; + } + console.log("[V] interp OK, adv:", result.xAdvance); setIsInterpolating(true); engine.emitGlyph(result); From d30c8a41b946896d59f3b03b9d85ea999db768c6 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 19:36:37 +0100 Subject: [PATCH 16/41] debug: rust-side master snapshot logging --- crates/shift-node/src/font_engine.rs | 35 ++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/shift-node/src/font_engine.rs b/crates/shift-node/src/font_engine.rs index 02b435a0..24e2e270 100644 --- a/crates/shift-node/src/font_engine.rs +++ b/crates/shift-node/src/font_engine.rs @@ -574,11 +574,42 @@ impl FontEngine { let mut masters: Vec = Vec::new(); + eprintln!( + "[master_snapshots] glyph='{}' total_layers={} sources={}", + glyph_name, + glyph.layers().len(), + self.font.sources().len() + ); + for source in self.font.sources() { let layer_id = source.layer_id(); let layer = match glyph.layer(layer_id) { - Some(l) if !l.contours().is_empty() => l, - _ => continue, + Some(l) if !l.contours().is_empty() => { + eprintln!( + " source='{}' layer_id={:?} contours={}", + source.name(), + layer_id, + l.contours().len() + ); + l + } + Some(l) => { + eprintln!( + " source='{}' layer_id={:?} SKIPPED (empty, contours={})", + source.name(), + layer_id, + l.contours().len() + ); + continue; + } + None => { + eprintln!( + " source='{}' layer_id={:?} SKIPPED (no layer)", + source.name(), + layer_id + ); + continue; + } }; let primary_unicode = glyph.primary_unicode().unwrap_or(0); From 2a8a1107da69e33d540aff80d505223f5644c01a Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 19:40:32 +0100 Subject: [PATCH 17/41] Filter incompatible masters instead of failing, remove debug logs Instead of returning null when any master has different contour counts, filter to only masters compatible with the default (first) master. This handles fonts where support layers or user edits cause contour count mismatches in some masters. Incompatible masters are silently excluded from interpolation. Remove all debug logging. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/VariationPanel.tsx | 16 ++------- .../src/lib/interpolation/interpolate.test.ts | 4 +-- .../src/lib/interpolation/interpolate.ts | 29 +++++++++++---- crates/shift-node/src/font_engine.rs | 35 ++----------------- 4 files changed, 30 insertions(+), 54 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/VariationPanel.tsx b/apps/desktop/src/renderer/src/components/VariationPanel.tsx index e00b5857..4b6225c0 100644 --- a/apps/desktop/src/renderer/src/components/VariationPanel.tsx +++ b/apps/desktop/src/renderer/src/components/VariationPanel.tsx @@ -3,7 +3,7 @@ import type { Axis } from "@shift/types"; import { SidebarSection } from "./sidebar-right/SidebarSection"; import { getEditor } from "@/store/store"; import { useSignalState } from "@/lib/reactive"; -import { checkCompatibility, interpolateGlyph, type MasterSnapshot } from "@/lib/interpolation/interpolate"; +import { interpolateGlyph, type MasterSnapshot } from "@/lib/interpolation/interpolate"; /** Variation axis slider panel — shown when a variable font is loaded. */ export const VariationPanel = () => { @@ -45,13 +45,7 @@ export const VariationPanel = () => { return; } - const loaded = engine.getGlyphMasterSnapshots(editingGlyph); - mastersRef.current = loaded; - if (loaded) { - console.log("[V] masters loaded for", editingGlyph, loaded.map(m => - `${m.sourceName}:${m.snapshot.contours.length}c` - )); - } + mastersRef.current = engine.getGlyphMasterSnapshots(editingGlyph); setIsInterpolating(false); }, [axes, editingGlyph, engine]); @@ -64,11 +58,7 @@ export const VariationPanel = () => { if (!ms || ms.length < 2) return; const result = interpolateGlyph(ms, axes, newLocation); - if (!result) { - console.log("[V] interp null, compat:", checkCompatibility(ms)); - return; - } - console.log("[V] interp OK, adv:", result.xAdvance); + if (!result) return; setIsInterpolating(true); engine.emitGlyph(result); diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts index 4df129d3..9b1caaf9 100644 --- a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts @@ -167,7 +167,7 @@ describe("interpolateGlyph", () => { expect(result!.contours[0].points[0].y).toBeCloseTo(400); }); - it("returns null for incompatible masters", () => { + it("falls back to default master when all others are incompatible", () => { const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 0, y: 0 }]), 500); const bold = makeMaster( "Bold", @@ -180,7 +180,7 @@ describe("interpolateGlyph", () => { ); const result = interpolateGlyph([light, bold], axes, { wght: 500 }); - expect(result).toBeNull(); + expect(result).toBe(light.snapshot); }); it("preserves point metadata from reference master", () => { diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts index 07d319f2..f3785076 100644 --- a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts @@ -358,21 +358,38 @@ export function interpolateGlyph( if (uniqueMasters.length < 2) return uniqueMasters[0]?.snapshot ?? null; - const error = checkCompatibility(uniqueMasters); - if (error) return null; + // Filter to only masters compatible with the first (default) master + const ref = uniqueMasters[0].snapshot; + const compatIndices = [0]; + for (let i = 1; i < uniqueMasters.length; i++) { + const other = uniqueMasters[i].snapshot; + if (ref.contours.length !== other.contours.length) continue; + let compatible = true; + for (let c = 0; c < ref.contours.length; c++) { + if (ref.contours[c].points.length !== other.contours[c].points.length) { + compatible = false; + break; + } + } + if (compatible) compatIndices.push(i); + } + const compatMasters = compatIndices.map((i) => uniqueMasters[i]); + const compatLocations = compatIndices.map((i) => uniqueLocations[i]); + + if (compatMasters.length < 2) return compatMasters[0]?.snapshot ?? null; const axisOrder = axes.map((a) => a.tag); // Build model — wrap in try/catch for robustness let model: VariationModelData; try { - model = buildVariationModel(uniqueLocations, axisOrder); + model = buildVariationModel(compatLocations, axisOrder); } catch { return null; } // Flatten master values into number arrays - const masterFlats = uniqueMasters.map((m) => snapshotToFlat(m.snapshot)); + const masterFlats = compatMasters.map((m) => snapshotToFlat(m.snapshot)); // Compute deltas const deltas: number[][] = []; @@ -398,7 +415,7 @@ export function interpolateGlyph( result = result === null ? contribution : flatAdd(result, contribution); } - if (!result) return uniqueMasters[0].snapshot; + if (!result) return compatMasters[0].snapshot; - return flatToSnapshot(result, uniqueMasters[0].snapshot); + return flatToSnapshot(result, compatMasters[0].snapshot); } diff --git a/crates/shift-node/src/font_engine.rs b/crates/shift-node/src/font_engine.rs index 24e2e270..02b435a0 100644 --- a/crates/shift-node/src/font_engine.rs +++ b/crates/shift-node/src/font_engine.rs @@ -574,42 +574,11 @@ impl FontEngine { let mut masters: Vec = Vec::new(); - eprintln!( - "[master_snapshots] glyph='{}' total_layers={} sources={}", - glyph_name, - glyph.layers().len(), - self.font.sources().len() - ); - for source in self.font.sources() { let layer_id = source.layer_id(); let layer = match glyph.layer(layer_id) { - Some(l) if !l.contours().is_empty() => { - eprintln!( - " source='{}' layer_id={:?} contours={}", - source.name(), - layer_id, - l.contours().len() - ); - l - } - Some(l) => { - eprintln!( - " source='{}' layer_id={:?} SKIPPED (empty, contours={})", - source.name(), - layer_id, - l.contours().len() - ); - continue; - } - None => { - eprintln!( - " source='{}' layer_id={:?} SKIPPED (no layer)", - source.name(), - layer_id - ); - continue; - } + Some(l) if !l.contours().is_empty() => l, + _ => continue, }; let primary_unicode = glyph.primary_unicode().unwrap_or(0); From 83df34cb0ba1fd9c859b3feb861cf3b3e3aa391c Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 19:43:54 +0100 Subject: [PATCH 18/41] Use majority-vote compatibility filtering for interpolation When the default master has different contour counts (e.g. from support layers loaded into the editing glyph), the old approach filtered out all other masters since they didn't match the first. Now finds the most common contour signature across all masters and keeps only those, ensuring the largest compatible group interpolates. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/interpolation/interpolate.ts | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts index f3785076..230bbcc4 100644 --- a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts @@ -358,26 +358,40 @@ export function interpolateGlyph( if (uniqueMasters.length < 2) return uniqueMasters[0]?.snapshot ?? null; - // Filter to only masters compatible with the first (default) master - const ref = uniqueMasters[0].snapshot; - const compatIndices = [0]; - for (let i = 1; i < uniqueMasters.length; i++) { - const other = uniqueMasters[i].snapshot; - if (ref.contours.length !== other.contours.length) continue; - let compatible = true; - for (let c = 0; c < ref.contours.length; c++) { - if (ref.contours[c].points.length !== other.contours[c].points.length) { - compatible = false; - break; - } + // Find the largest group of mutually compatible masters. + // Build a compatibility signature per master: "contourCount:p0,p1,p2..." + const signatures = uniqueMasters.map((m) => { + const s = m.snapshot; + return `${s.contours.length}:${s.contours.map((c) => c.points.length).join(",")}`; + }); + + // Find the most common signature + const sigCounts = new Map(); + for (const sig of signatures) { + sigCounts.set(sig, (sigCounts.get(sig) ?? 0) + 1); + } + let bestSig = signatures[0]; + let bestCount = 0; + for (const [sig, count] of sigCounts) { + if (count > bestCount) { + bestSig = sig; + bestCount = count; } - if (compatible) compatIndices.push(i); } + + const compatIndices = signatures + .map((sig, i) => (sig === bestSig ? i : -1)) + .filter((i) => i >= 0); const compatMasters = compatIndices.map((i) => uniqueMasters[i]); const compatLocations = compatIndices.map((i) => uniqueLocations[i]); if (compatMasters.length < 2) return compatMasters[0]?.snapshot ?? null; + // The VariationModel requires a default location (empty object). + // If the default master was filtered out, we can't build the model. + const hasDefault = compatLocations.some((loc) => Object.keys(loc).length === 0); + if (!hasDefault) return null; + const axisOrder = axes.map((a) => a.tag); // Build model — wrap in try/catch for robustness From 727006f26f647c134b3686db40d564e76032bb74 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 19:44:49 +0100 Subject: [PATCH 19/41] Add directBlend fallback when default master is incompatible If the default master is filtered out (extra contours from support layers), the VariationModel can't be built. Fall back to inverse- distance weighted blending of the compatible masters. Keeps one diagnostic log to help debug the root cause. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/interpolation/interpolate.ts | 55 ++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts index 230bbcc4..43d50fb5 100644 --- a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts @@ -328,6 +328,53 @@ function flatToSnapshot(flat: number[], ref: GlyphSnapshot): GlyphSnapshot { // ── Public API ────────────────────────────────────────────────────── +/** + * Fallback: inverse-distance weighted blending when the VariationModel + * can't be built (e.g. default master filtered out for incompatibility). + */ +function directBlend( + masters: MasterSnapshot[], + axes: Axis[], + target: Record, +): GlyphSnapshot | null { + if (masters.length < 2) return masters[0]?.snapshot ?? null; + + const normalizedTarget: Record = {}; + for (const axis of axes) { + normalizedTarget[axis.tag] = normalizeAxisValue(target[axis.tag] ?? axis.default, axis); + } + + const distances = masters.map((m) => { + let dist = 0; + for (const axis of axes) { + const val = m.location.values[axis.tag] ?? axis.default; + const n = normalizeAxisValue(val, axis); + const diff = (normalizedTarget[axis.tag] ?? 0) - n; + dist += diff * diff; + } + return Math.sqrt(dist); + }); + + // Exact match + for (let i = 0; i < distances.length; i++) { + if (distances[i] < 1e-10) return masters[i].snapshot; + } + + const invDist = distances.map((d) => 1 / d); + const sum = invDist.reduce((a, b) => a + b, 0); + const weights = invDist.map((w) => w / sum); + + const flats = masters.map((m) => snapshotToFlat(m.snapshot)); + let result = Array.from({ length: flats[0].length }).fill(0); + for (let i = 0; i < flats.length; i++) { + for (let j = 0; j < result.length; j++) { + result[j] += flats[i][j] * weights[i]; + } + } + + return flatToSnapshot(result, masters[0].snapshot); +} + /** * Interpolate a glyph at a target design-space location using the * OpenType VariationModel algorithm (support regions + delta decomposition). @@ -388,9 +435,13 @@ export function interpolateGlyph( if (compatMasters.length < 2) return compatMasters[0]?.snapshot ?? null; // The VariationModel requires a default location (empty object). - // If the default master was filtered out, we can't build the model. + // If the default master was filtered out, fall back to direct blending. const hasDefault = compatLocations.some((loc) => Object.keys(loc).length === 0); - if (!hasDefault) return null; + if (!hasDefault) { + console.log("[interp] default filtered out. compatMasters:", compatMasters.length, + "sigs:", [...sigCounts.entries()].map(([s, c]) => `${s}(${c})`)); + return directBlend(compatMasters, axes, target); + } const axisOrder = axes.map((a) => a.tag); From a0bd24b7b8e0b1d19bbdad9c98cf45e81f7d2985 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 19:49:21 +0100 Subject: [PATCH 20/41] Sort contours deterministically and filter empty ones in master snapshots ContourIds differ between masters loaded from separate UFOs, so HashMap iteration order is random per master. This caused contour point counts like [4,4,7] vs [7,4,4] to appear incompatible even though they're the same contours in different order. Sort by point count (descending) then first point coordinates. Also filter out contours with 0 points. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/interpolation/interpolate.ts | 2 -- crates/shift-node/src/font_engine.rs | 16 +++++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts index 43d50fb5..d812eec1 100644 --- a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts @@ -438,8 +438,6 @@ export function interpolateGlyph( // If the default master was filtered out, fall back to direct blending. const hasDefault = compatLocations.some((loc) => Object.keys(loc).length === 0); if (!hasDefault) { - console.log("[interp] default filtered out. compatMasters:", compatMasters.length, - "sigs:", [...sigCounts.entries()].map(([s, c]) => `${s}(${c})`)); return directBlend(compatMasters, axes, target); } diff --git a/crates/shift-node/src/font_engine.rs b/crates/shift-node/src/font_engine.rs index 02b435a0..5de0a702 100644 --- a/crates/shift-node/src/font_engine.rs +++ b/crates/shift-node/src/font_engine.rs @@ -583,12 +583,26 @@ impl FontEngine { let primary_unicode = glyph.primary_unicode().unwrap_or(0); - let contours = layer + let mut contours: Vec<_> = layer .contours() .values() + .filter(|c| !c.points().is_empty()) .map(shift_core::snapshot::ContourSnapshot::from) .collect(); + // Sort contours deterministically — ContourIds differ between masters + // loaded from separate UFOs, so HashMap order is random. + contours.sort_by(|a, b| { + let len_cmp = a.points.len().cmp(&b.points.len()).reverse(); + if len_cmp != std::cmp::Ordering::Equal { + return len_cmp; + } + // Tiebreak: first point coordinates + let a_pt = a.points.first().map(|p| (p.x.to_bits(), p.y.to_bits())); + let b_pt = b.points.first().map(|p| (p.x.to_bits(), p.y.to_bits())); + a_pt.cmp(&b_pt) + }); + let anchors = layer .anchors_iter() .map(shift_core::snapshot::AnchorSnapshot::from) From 3c5b9b19945aab1b89456413455df8e004340476 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 19:52:44 +0100 Subject: [PATCH 21/41] Add edge case tests for interpolation and master snapshots TS tests: duplicate location dedup, incompatible master filtering (majority vote), directBlend fallback when default is incompatible, graceful fallback when all masters differ. NAPI tests: empty contours excluded, consistent contour order across masters, master snapshots work for the currently editing glyph. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/interpolation/interpolate.test.ts | 70 +++++++++++++++++++ .../__test__/font_integration.spec.mjs | 40 +++++++++++ 2 files changed, 110 insertions(+) diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts index 9b1caaf9..bb7a7e3e 100644 --- a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts @@ -233,4 +233,74 @@ describe("interpolateGlyph", () => { expect(result!.contours[0].points[0].y).toBeCloseTo(50); expect(result!.xAdvance).toBeCloseTo(550); }); + + it("deduplicates masters at the same normalized location", () => { + const m1 = makeMaster("Sans", { wght: 0 }, makeContour([{ x: 100, y: 200 }]), 500); + const m2 = makeMaster("Slab", { wght: 0 }, makeContour([{ x: 100, y: 200 }]), 500); + const m3 = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 200, y: 400 }]), 700); + + const result = interpolateGlyph([m1, m2, m3], axes, { wght: 500 }); + + expect(result).not.toBeNull(); + expect(result!.contours[0].points[0].x).toBeCloseTo(150); + }); + + it("filters incompatible masters and interpolates with compatible majority", () => { + const compatible1 = makeMaster("A", { wght: 0 }, makeContour([{ x: 0, y: 0 }]), 500); + const compatible2 = makeMaster("B", { wght: 1000 }, makeContour([{ x: 200, y: 0 }]), 700); + const incompatible: MasterSnapshot = { + sourceId: "C", + sourceName: "C", + location: { values: { wght: 500 } }, + snapshot: makeSnapshot( + [makeContour([{ x: 100, y: 0 }]), makeContour([{ x: 50, y: 50 }])], + 600, + ), + }; + + const result = interpolateGlyph([compatible1, incompatible, compatible2], axes, { wght: 500 }); + + expect(result).not.toBeNull(); + expect(result!.contours[0].points[0].x).toBeCloseTo(100); + }); + + it("uses directBlend when default master is the incompatible one", () => { + // Default (wght=0) has extra contour, others are compatible + const defaultMaster: MasterSnapshot = { + sourceId: "Default", + sourceName: "Default", + location: { values: { wght: 0 } }, + snapshot: makeSnapshot( + [makeContour([{ x: 0, y: 0 }]), makeContour([{ x: 50, y: 50 }])], + 500, + ), + }; + const mid = makeMaster("Mid", { wght: 500 }, makeContour([{ x: 100, y: 0 }]), 600); + const bold = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 200, y: 0 }]), 700); + + const result = interpolateGlyph([defaultMaster, mid, bold], axes, { wght: 750 }); + + expect(result).not.toBeNull(); + // Should interpolate between mid and bold via directBlend + expect(result!.contours[0].points[0].x).toBeGreaterThan(100); + expect(result!.contours[0].points[0].x).toBeLessThan(200); + }); + + it("returns default snapshot when all masters are incompatible with each other", () => { + const m1 = makeMaster("A", { wght: 0 }, makeContour([{ x: 0, y: 0 }]), 500); + const m2: MasterSnapshot = { + sourceId: "B", + sourceName: "B", + location: { values: { wght: 1000 } }, + snapshot: makeSnapshot( + [makeContour([{ x: 200, y: 0 }]), makeContour([{ x: 100, y: 100 }])], + 700, + ), + }; + + const result = interpolateGlyph([m1, m2], axes, { wght: 500 }); + + // Falls back to first master since no compatible group + expect(result).toBe(m1.snapshot); + }); }); diff --git a/crates/shift-node/__test__/font_integration.spec.mjs b/crates/shift-node/__test__/font_integration.spec.mjs index 52816063..f56324d0 100644 --- a/crates/shift-node/__test__/font_integration.spec.mjs +++ b/crates/shift-node/__test__/font_integration.spec.mjs @@ -616,4 +616,44 @@ describe("FontEngine Integration - Designspace", () => { expect(m.snapshot.contours.length).toBeGreaterThan(0); } }); + + it("excludes empty contours from master snapshots", () => { + const engine = new FontEngine(); + engine.loadFont(MUTATORSANS_DESIGNSPACE); + const json = engine.getGlyphMasterSnapshots("A"); + const masters = JSON.parse(json); + for (const m of masters) { + for (const contour of m.snapshot.contours) { + expect(contour.points.length).toBeGreaterThan(0); + } + } + }); + + it("returns consistent contour order across masters", () => { + const engine = new FontEngine(); + engine.loadFont(MUTATORSANS_DESIGNSPACE); + const json = engine.getGlyphMasterSnapshots("A"); + const masters = JSON.parse(json); + + // All masters should have the same contour signature (point counts per contour) + const sigs = masters.map(m => + m.snapshot.contours.map(c => c.points.length).join(",") + ); + const uniqueSigs = new Set(sigs); + expect(uniqueSigs.size).toBe(1); + }); + + it("returns master snapshots for the currently editing glyph", () => { + const engine = new FontEngine(); + engine.loadFont(MUTATORSANS_DESIGNSPACE); + engine.startEditSession({ glyphName: "A", unicode: 65 }); + + // Glyph is taken from font during session, but snapshots should still work + const json = engine.getGlyphMasterSnapshots("A"); + expect(json).not.toBeNull(); + const masters = JSON.parse(json); + expect(masters.length).toBeGreaterThanOrEqual(4); + + engine.endEditSession(); + }); }); From 31c1f75dba80703301f361d6837cbed1ef494b14 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 20:01:18 +0100 Subject: [PATCH 22/41] Remove directBlend, use default-as-reference filtering like Fontra Replace majority-vote compatibility filtering and directBlend fallback with Fontra's approach: always use the default master as the reference, filter out incompatible sources, build the VariationModel with the remaining compatible set. The default master is always included. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/interpolation/interpolate.test.ts | 23 +++-- .../src/lib/interpolation/interpolate.ts | 93 ++++--------------- 2 files changed, 31 insertions(+), 85 deletions(-) diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts index bb7a7e3e..800ad7b2 100644 --- a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts @@ -245,9 +245,10 @@ describe("interpolateGlyph", () => { expect(result!.contours[0].points[0].x).toBeCloseTo(150); }); - it("filters incompatible masters and interpolates with compatible majority", () => { - const compatible1 = makeMaster("A", { wght: 0 }, makeContour([{ x: 0, y: 0 }]), 500); - const compatible2 = makeMaster("B", { wght: 1000 }, makeContour([{ x: 200, y: 0 }]), 700); + it("filters incompatible masters and interpolates with default-compatible set", () => { + // Default (A at wght=0) is compatible with B, but C has extra contour + const defaultMaster = makeMaster("A", { wght: 0 }, makeContour([{ x: 0, y: 0 }]), 500); + const compatible = makeMaster("B", { wght: 1000 }, makeContour([{ x: 200, y: 0 }]), 700); const incompatible: MasterSnapshot = { sourceId: "C", sourceName: "C", @@ -258,14 +259,18 @@ describe("interpolateGlyph", () => { ), }; - const result = interpolateGlyph([compatible1, incompatible, compatible2], axes, { wght: 500 }); + const result = interpolateGlyph( + [defaultMaster, incompatible, compatible], + axes, + { wght: 500 }, + ); expect(result).not.toBeNull(); expect(result!.contours[0].points[0].x).toBeCloseTo(100); }); - it("uses directBlend when default master is the incompatible one", () => { - // Default (wght=0) has extra contour, others are compatible + it("returns default snapshot when default master is incompatible with others", () => { + // Default (wght=0) has extra contour — no compatible group includes the default const defaultMaster: MasterSnapshot = { sourceId: "Default", sourceName: "Default", @@ -280,10 +285,8 @@ describe("interpolateGlyph", () => { const result = interpolateGlyph([defaultMaster, mid, bold], axes, { wght: 750 }); - expect(result).not.toBeNull(); - // Should interpolate between mid and bold via directBlend - expect(result!.contours[0].points[0].x).toBeGreaterThan(100); - expect(result!.contours[0].points[0].x).toBeLessThan(200); + // Default is the reference but incompatible with others → only default remains + expect(result).toBe(defaultMaster.snapshot); }); it("returns default snapshot when all masters are incompatible with each other", () => { diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts index d812eec1..630c4d1b 100644 --- a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts @@ -326,58 +326,19 @@ function flatToSnapshot(flat: number[], ref: GlyphSnapshot): GlyphSnapshot { return { ...ref, xAdvance, contours }; } -// ── Public API ────────────────────────────────────────────────────── - -/** - * Fallback: inverse-distance weighted blending when the VariationModel - * can't be built (e.g. default master filtered out for incompatibility). - */ -function directBlend( - masters: MasterSnapshot[], - axes: Axis[], - target: Record, -): GlyphSnapshot | null { - if (masters.length < 2) return masters[0]?.snapshot ?? null; - - const normalizedTarget: Record = {}; - for (const axis of axes) { - normalizedTarget[axis.tag] = normalizeAxisValue(target[axis.tag] ?? axis.default, axis); - } - - const distances = masters.map((m) => { - let dist = 0; - for (const axis of axes) { - const val = m.location.values[axis.tag] ?? axis.default; - const n = normalizeAxisValue(val, axis); - const diff = (normalizedTarget[axis.tag] ?? 0) - n; - dist += diff * diff; - } - return Math.sqrt(dist); - }); - - // Exact match - for (let i = 0; i < distances.length; i++) { - if (distances[i] < 1e-10) return masters[i].snapshot; - } - - const invDist = distances.map((d) => 1 / d); - const sum = invDist.reduce((a, b) => a + b, 0); - const weights = invDist.map((w) => w / sum); - - const flats = masters.map((m) => snapshotToFlat(m.snapshot)); - let result = Array.from({ length: flats[0].length }).fill(0); - for (let i = 0; i < flats.length; i++) { - for (let j = 0; j < result.length; j++) { - result[j] += flats[i][j] * weights[i]; - } - } - - return flatToSnapshot(result, masters[0].snapshot); +function contourSignature(snapshot: GlyphSnapshot): string { + return `${snapshot.contours.length}:${snapshot.contours.map((c) => c.points.length).join(",")}`; } +// ── Public API ────────────────────────────────────────────────────── + /** * Interpolate a glyph at a target design-space location using the * OpenType VariationModel algorithm (support regions + delta decomposition). + * + * Follows Fontra's approach: use the default master as the compatibility + * reference, filter out incompatible sources, and build the VariationModel + * with the compatible subset. The default master is always included. */ export function interpolateGlyph( masters: MasterSnapshot[], @@ -405,42 +366,24 @@ export function interpolateGlyph( if (uniqueMasters.length < 2) return uniqueMasters[0]?.snapshot ?? null; - // Find the largest group of mutually compatible masters. - // Build a compatibility signature per master: "contourCount:p0,p1,p2..." - const signatures = uniqueMasters.map((m) => { - const s = m.snapshot; - return `${s.contours.length}:${s.contours.map((c) => c.points.length).join(",")}`; - }); + // Find the default master (at normalized location {}) + const defaultIdx = uniqueLocations.findIndex((loc) => Object.keys(loc).length === 0); + if (defaultIdx < 0) return null; - // Find the most common signature - const sigCounts = new Map(); - for (const sig of signatures) { - sigCounts.set(sig, (sigCounts.get(sig) ?? 0) + 1); - } - let bestSig = signatures[0]; - let bestCount = 0; - for (const [sig, count] of sigCounts) { - if (count > bestCount) { - bestSig = sig; - bestCount = count; - } - } + // Use the default master as the compatibility reference (matches Fontra). + // Filter out masters whose contour structure doesn't match the default. + const ref = uniqueMasters[defaultIdx].snapshot; + const refSig = contourSignature(ref); - const compatIndices = signatures - .map((sig, i) => (sig === bestSig ? i : -1)) + const compatIndices = uniqueMasters + .map((m, i) => (contourSignature(m.snapshot) === refSig ? i : -1)) .filter((i) => i >= 0); + const compatMasters = compatIndices.map((i) => uniqueMasters[i]); const compatLocations = compatIndices.map((i) => uniqueLocations[i]); if (compatMasters.length < 2) return compatMasters[0]?.snapshot ?? null; - // The VariationModel requires a default location (empty object). - // If the default master was filtered out, fall back to direct blending. - const hasDefault = compatLocations.some((loc) => Object.keys(loc).length === 0); - if (!hasDefault) { - return directBlend(compatMasters, axes, target); - } - const axisOrder = axes.map((a) => a.tag); // Build model — wrap in try/catch for robustness From c3a4fc7873a5031d109adf07f38b17113e7c5518 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 20:10:25 +0100 Subject: [PATCH 23/41] Replace flat-array interpolation with structured itemwise arithmetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port Fontra's per-source error handling approach: operate on GlyphSnapshot structure directly instead of flattening to number[]. subSnapshot/ addSnapshot/mulScalarSnapshot throw IncompatibleError when contour or point counts differ. During delta computation, errors are caught per-source — incompatible sources get a zero delta and are reported in SourceError[]. interpolateGlyph now returns InterpolationResult { instance, errors } instead of GlyphSnapshot | null. The errors array is wired through but not yet surfaced in UI (ready for compatibility badges later). Removes directBlend fallback and pre-filtering — the model handles all sources and gracefully degrades when some are incompatible. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/VariationPanel.tsx | 2 +- .../src/lib/interpolation/interpolate.test.ts | 239 ++++++++--------- .../src/lib/interpolation/interpolate.ts | 251 +++++++++++------- 3 files changed, 264 insertions(+), 228 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/VariationPanel.tsx b/apps/desktop/src/renderer/src/components/VariationPanel.tsx index 4b6225c0..d0bce17c 100644 --- a/apps/desktop/src/renderer/src/components/VariationPanel.tsx +++ b/apps/desktop/src/renderer/src/components/VariationPanel.tsx @@ -61,7 +61,7 @@ export const VariationPanel = () => { if (!result) return; setIsInterpolating(true); - engine.emitGlyph(result); + engine.emitGlyph(result.instance); }, [location, axes, engine], ); diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts index 800ad7b2..d5187443 100644 --- a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts @@ -4,6 +4,7 @@ import { normalizeAxisValue, checkCompatibility, type MasterSnapshot, + type InterpolationResult, } from "./interpolate"; import type { Axis, GlyphSnapshot, ContourSnapshot } from "@shift/types"; @@ -59,6 +60,12 @@ function makeMaster( }; } +/** Unwrap a non-null InterpolationResult */ +function unwrap(result: InterpolationResult | null): InterpolationResult { + expect(result).not.toBeNull(); + return result!; +} + describe("normalizeAxisValue", () => { const axis = makeAxis({ minimum: 100, default: 400, maximum: 900 }); @@ -81,24 +88,8 @@ describe("normalizeAxisValue", () => { describe("checkCompatibility", () => { it("returns null for compatible masters", () => { - const light = makeMaster( - "Light", - { wght: 0 }, - makeContour([ - { x: 0, y: 0 }, - { x: 100, y: 0 }, - ]), - 500, - ); - const bold = makeMaster( - "Bold", - { wght: 1000 }, - makeContour([ - { x: 10, y: 0 }, - { x: 110, y: 0 }, - ]), - 600, - ); + const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 0, y: 0 }, { x: 100, y: 0 }]), 500); + const bold = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 10, y: 0 }, { x: 110, y: 0 }]), 600); expect(checkCompatibility([light, bold])).toBeNull(); }); @@ -113,15 +104,7 @@ describe("checkCompatibility", () => { it("reports point count mismatch", () => { const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 0, y: 0 }]), 500); - const bold = makeMaster( - "Bold", - { wght: 1000 }, - makeContour([ - { x: 0, y: 0 }, - { x: 100, y: 0 }, - ]), - 600, - ); + const bold = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 0, y: 0 }, { x: 100, y: 0 }]), 600); expect(checkCompatibility([light, bold])).toContain("points"); }); }); @@ -131,107 +114,70 @@ describe("interpolateGlyph", () => { it("returns the single master's snapshot when only one master", () => { const master = makeMaster("Regular", { wght: 0 }, makeContour([{ x: 100, y: 200 }]), 500); - const result = interpolateGlyph([master], axes, { wght: 0 }); - expect(result).toBe(master.snapshot); + const result = unwrap(interpolateGlyph([master], axes, { wght: 0 })); + expect(result.instance).toBe(master.snapshot); + expect(result.errors).toHaveLength(0); }); it("interpolates at midpoint between two masters", () => { const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 100, y: 200 }]), 500); const bold = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 200, y: 400 }]), 700); - const result = interpolateGlyph([light, bold], axes, { wght: 500 }); + const { instance } = unwrap(interpolateGlyph([light, bold], axes, { wght: 500 })); - expect(result).not.toBeNull(); - expect(result!.contours[0].points[0].x).toBeCloseTo(150); - expect(result!.contours[0].points[0].y).toBeCloseTo(300); - expect(result!.xAdvance).toBeCloseTo(600); + expect(instance.contours[0].points[0].x).toBeCloseTo(150); + expect(instance.contours[0].points[0].y).toBeCloseTo(300); + expect(instance.xAdvance).toBeCloseTo(600); }); it("returns default master at default location", () => { const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 100, y: 200 }]), 500); const bold = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 200, y: 400 }]), 700); - const result = interpolateGlyph([light, bold], axes, { wght: 0 }); + const { instance } = unwrap(interpolateGlyph([light, bold], axes, { wght: 0 })); - expect(result!.contours[0].points[0].x).toBeCloseTo(100); - expect(result!.contours[0].points[0].y).toBeCloseTo(200); + expect(instance.contours[0].points[0].x).toBeCloseTo(100); + expect(instance.contours[0].points[0].y).toBeCloseTo(200); }); it("returns non-default master at its location", () => { const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 100, y: 200 }]), 500); const bold = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 200, y: 400 }]), 700); - const result = interpolateGlyph([light, bold], axes, { wght: 1000 }); - - expect(result!.contours[0].points[0].x).toBeCloseTo(200); - expect(result!.contours[0].points[0].y).toBeCloseTo(400); - }); - - it("falls back to default master when all others are incompatible", () => { - const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 0, y: 0 }]), 500); - const bold = makeMaster( - "Bold", - { wght: 1000 }, - makeContour([ - { x: 0, y: 0 }, - { x: 100, y: 0 }, - ]), - 600, - ); + const { instance } = unwrap(interpolateGlyph([light, bold], axes, { wght: 1000 })); - const result = interpolateGlyph([light, bold], axes, { wght: 500 }); - expect(result).toBe(light.snapshot); + expect(instance.contours[0].points[0].x).toBeCloseTo(200); + expect(instance.contours[0].points[0].y).toBeCloseTo(400); }); it("preserves point metadata from reference master", () => { const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 100, y: 200 }]), 500); const bold = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 200, y: 400 }]), 700); - const result = interpolateGlyph([light, bold], axes, { wght: 500 }); + const { instance } = unwrap(interpolateGlyph([light, bold], axes, { wght: 500 })); - expect(result!.contours[0].points[0].id).toBe(light.snapshot.contours[0].points[0].id); - expect(result!.contours[0].points[0].pointType).toBe("onCurve"); - expect(result!.contours[0].id).toBe(light.snapshot.contours[0].id); + expect(instance.contours[0].points[0].id).toBe(light.snapshot.contours[0].points[0].id); + expect(instance.contours[0].points[0].pointType).toBe("onCurve"); + expect(instance.contours[0].id).toBe(light.snapshot.contours[0].id); }); it("interpolates with 4 masters on 2 axes", () => { const wdthAxis = makeAxis({ tag: "wdth", name: "Width" }); const wghtAxis = makeAxis({ tag: "wght", name: "Weight" }); - const lc = makeMaster( - "LightCondensed", - { wdth: 0, wght: 0 }, - makeContour([{ x: 0, y: 0 }]), - 400, - ); - const bc = makeMaster( - "BoldCondensed", - { wdth: 0, wght: 1000 }, - makeContour([{ x: 100, y: 0 }]), - 500, - ); - const lw = makeMaster( - "LightWide", - { wdth: 1000, wght: 0 }, - makeContour([{ x: 0, y: 100 }]), - 600, - ); - const bw = makeMaster( - "BoldWide", - { wdth: 1000, wght: 1000 }, - makeContour([{ x: 100, y: 100 }]), - 700, - ); + const lc = makeMaster("LightCondensed", { wdth: 0, wght: 0 }, makeContour([{ x: 0, y: 0 }]), 400); + const bc = makeMaster("BoldCondensed", { wdth: 0, wght: 1000 }, makeContour([{ x: 100, y: 0 }]), 500); + const lw = makeMaster("LightWide", { wdth: 1000, wght: 0 }, makeContour([{ x: 0, y: 100 }]), 600); + const bw = makeMaster("BoldWide", { wdth: 1000, wght: 1000 }, makeContour([{ x: 100, y: 100 }]), 700); - const result = interpolateGlyph([lc, bc, lw, bw], [wdthAxis, wghtAxis], { - wdth: 500, - wght: 500, - }); + const { instance, errors } = unwrap( + interpolateGlyph([lc, bc, lw, bw], [wdthAxis, wghtAxis], { wdth: 500, wght: 500 }), + ); - expect(result).not.toBeNull(); - expect(result!.contours[0].points[0].x).toBeCloseTo(50); - expect(result!.contours[0].points[0].y).toBeCloseTo(50); - expect(result!.xAdvance).toBeCloseTo(550); + expect(errors).toHaveLength(0); + expect(instance.contours[0].points[0].x).toBeCloseTo(50); + expect(instance.contours[0].points[0].y).toBeCloseTo(50); + expect(instance.xAdvance).toBeCloseTo(550); }); it("deduplicates masters at the same normalized location", () => { @@ -239,19 +185,21 @@ describe("interpolateGlyph", () => { const m2 = makeMaster("Slab", { wght: 0 }, makeContour([{ x: 100, y: 200 }]), 500); const m3 = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 200, y: 400 }]), 700); - const result = interpolateGlyph([m1, m2, m3], axes, { wght: 500 }); + const { instance } = unwrap(interpolateGlyph([m1, m2, m3], axes, { wght: 500 })); - expect(result).not.toBeNull(); - expect(result!.contours[0].points[0].x).toBeCloseTo(150); + expect(instance.contours[0].points[0].x).toBeCloseTo(150); }); +}); + +describe("interpolateGlyph — incompatible sources", () => { + const axes = [makeAxis()]; - it("filters incompatible masters and interpolates with default-compatible set", () => { - // Default (A at wght=0) is compatible with B, but C has extra contour - const defaultMaster = makeMaster("A", { wght: 0 }, makeContour([{ x: 0, y: 0 }]), 500); - const compatible = makeMaster("B", { wght: 1000 }, makeContour([{ x: 200, y: 0 }]), 700); + it("reports incompatible source and still produces a result", () => { + const defaultMaster = makeMaster("Default", { wght: 0 }, makeContour([{ x: 0, y: 0 }]), 500); + const compatible = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 200, y: 0 }]), 700); const incompatible: MasterSnapshot = { - sourceId: "C", - sourceName: "C", + sourceId: "Bad", + sourceName: "Bad", location: { values: { wght: 500 } }, snapshot: makeSnapshot( [makeContour([{ x: 100, y: 0 }]), makeContour([{ x: 50, y: 50 }])], @@ -259,51 +207,86 @@ describe("interpolateGlyph", () => { ), }; - const result = interpolateGlyph( - [defaultMaster, incompatible, compatible], - axes, - { wght: 500 }, + const { instance, errors } = unwrap( + interpolateGlyph([defaultMaster, incompatible, compatible], axes, { wght: 1000 }), ); - expect(result).not.toBeNull(); - expect(result!.contours[0].points[0].x).toBeCloseTo(100); + // Incompatible source reported in errors + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].sourceName).toBe("Bad"); + + // At wght=1000 (bold's exact location), result should match bold + expect(instance.contours[0].points[0].x).toBeCloseTo(200); }); - it("returns default snapshot when default master is incompatible with others", () => { - // Default (wght=0) has extra contour — no compatible group includes the default - const defaultMaster: MasterSnapshot = { - sourceId: "Default", - sourceName: "Default", - location: { values: { wght: 0 } }, + it("returns default when only one compatible source", () => { + const defaultMaster = makeMaster("Default", { wght: 0 }, makeContour([{ x: 0, y: 0 }]), 500); + const incompat: MasterSnapshot = { + sourceId: "Bad", + sourceName: "Bad", + location: { values: { wght: 1000 } }, snapshot: makeSnapshot( [makeContour([{ x: 0, y: 0 }]), makeContour([{ x: 50, y: 50 }])], - 500, + 700, ), }; - const mid = makeMaster("Mid", { wght: 500 }, makeContour([{ x: 100, y: 0 }]), 600); - const bold = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 200, y: 0 }]), 700); - const result = interpolateGlyph([defaultMaster, mid, bold], axes, { wght: 750 }); + const { instance, errors } = unwrap( + interpolateGlyph([defaultMaster, incompat], axes, { wght: 500 }), + ); - // Default is the reference but incompatible with others → only default remains - expect(result).toBe(defaultMaster.snapshot); + // Incompatible source zeroed out — result is just the default + expect(errors.length).toBeGreaterThan(0); + expect(instance.contours[0].points[0].x).toBeCloseTo(0); }); - it("returns default snapshot when all masters are incompatible with each other", () => { - const m1 = makeMaster("A", { wght: 0 }, makeContour([{ x: 0, y: 0 }]), 500); - const m2: MasterSnapshot = { - sourceId: "B", - sourceName: "B", + it("handles all sources incompatible except default", () => { + const defaultMaster = makeMaster("Default", { wght: 0 }, makeContour([{ x: 10, y: 20 }]), 500); + const bad1: MasterSnapshot = { + sourceId: "Bad1", + sourceName: "Bad1", + location: { values: { wght: 500 } }, + snapshot: makeSnapshot( + [makeContour([{ x: 0, y: 0 }]), makeContour([{ x: 50, y: 50 }])], + 600, + ), + }; + const bad2: MasterSnapshot = { + sourceId: "Bad2", + sourceName: "Bad2", location: { values: { wght: 1000 } }, snapshot: makeSnapshot( - [makeContour([{ x: 200, y: 0 }]), makeContour([{ x: 100, y: 100 }])], + [makeContour([{ x: 0, y: 0 }]), makeContour([{ x: 50, y: 50 }]), makeContour([{ x: 99, y: 99 }])], + 700, + ), + }; + + const { instance, errors } = unwrap( + interpolateGlyph([defaultMaster, bad1, bad2], axes, { wght: 500 }), + ); + + expect(errors).toHaveLength(2); + // Result is the default since both other deltas are zeroed + expect(instance.contours[0].points[0].x).toBeCloseTo(10); + expect(instance.contours[0].points[0].y).toBeCloseTo(20); + }); + + it("errors include the source name and a message", () => { + const defaultMaster = makeMaster("Default", { wght: 0 }, makeContour([{ x: 0, y: 0 }]), 500); + const incompat: MasterSnapshot = { + sourceId: "Wonky", + sourceName: "Wonky", + location: { values: { wght: 1000 } }, + snapshot: makeSnapshot( + [makeContour([{ x: 0, y: 0 }]), makeContour([{ x: 50, y: 50 }])], 700, ), }; - const result = interpolateGlyph([m1, m2], axes, { wght: 500 }); + const { errors } = unwrap(interpolateGlyph([defaultMaster, incompat], axes, { wght: 500 })); - // Falls back to first master since no compatible group - expect(result).toBe(m1.snapshot); + expect(errors).toHaveLength(1); + expect(errors[0].sourceName).toBe("Wonky"); + expect(errors[0].message).toContain("contour count"); }); }); diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts index 630c4d1b..d45980e1 100644 --- a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts @@ -4,6 +4,10 @@ * Ported from Fontra's var-model.js which is itself a port of * fontTools.varLib.models.VariationModel. Uses support-region box-splitting * and delta decomposition for correct multilinear interpolation. + * + * Incompatible sources are handled per-source during delta computation + * (matching Fontra's approach): if subItemwise throws for a source, that + * source's delta is zeroed out rather than failing the whole interpolation. */ import type { Axis, GlyphSnapshot, ContourSnapshot, PointSnapshot } from "@shift/types"; @@ -15,6 +19,13 @@ export interface MasterSnapshot { snapshot: GlyphSnapshot; } +/** Per-source error from delta computation. */ +export interface SourceError { + sourceIndex: number; + sourceName: string; + message: string; +} + // ── Axis normalization ────────────────────────────────────────────── export function normalizeAxisValue(value: number, axis: Axis): number { @@ -73,6 +84,94 @@ export function checkCompatibility(masters: MasterSnapshot[]): string | null { return null; } +// ── Itemwise arithmetic on GlyphSnapshot (matches Fontra) ─────────── +// +// These operate on the structured glyph data directly. If two snapshots +// have different contour/point counts, the operation throws — callers +// catch per-source to handle incompatibility gracefully. + +class IncompatibleError extends Error { + constructor(message: string) { + super(message); + this.name = "IncompatibleError"; + } +} + +function subPoints(a: PointSnapshot[], b: PointSnapshot[]): PointSnapshot[] { + if (a.length !== b.length) { + throw new IncompatibleError(`point count ${a.length} vs ${b.length}`); + } + return a.map((ap, i) => ({ ...ap, x: ap.x - b[i].x, y: ap.y - b[i].y })); +} + +function addPoints(a: PointSnapshot[], b: PointSnapshot[]): PointSnapshot[] { + if (a.length !== b.length) { + throw new IncompatibleError(`point count ${a.length} vs ${b.length}`); + } + return a.map((ap, i) => ({ ...ap, x: ap.x + b[i].x, y: ap.y + b[i].y })); +} + +function mulScalarPoints(pts: PointSnapshot[], s: number): PointSnapshot[] { + return pts.map((p) => ({ ...p, x: p.x * s, y: p.y * s })); +} + +function subContours(a: ContourSnapshot[], b: ContourSnapshot[]): ContourSnapshot[] { + if (a.length !== b.length) { + throw new IncompatibleError(`contour count ${a.length} vs ${b.length}`); + } + return a.map((ac, i) => ({ + ...ac, + points: subPoints(ac.points, b[i].points), + })); +} + +function addContours(a: ContourSnapshot[], b: ContourSnapshot[]): ContourSnapshot[] { + if (a.length !== b.length) { + throw new IncompatibleError(`contour count ${a.length} vs ${b.length}`); + } + return a.map((ac, i) => ({ + ...ac, + points: addPoints(ac.points, b[i].points), + })); +} + +function mulScalarContours(contours: ContourSnapshot[], s: number): ContourSnapshot[] { + return contours.map((c) => ({ ...c, points: mulScalarPoints(c.points, s) })); +} + +/** Subtract snapshot B from A: A - B */ +function subSnapshot(a: GlyphSnapshot, b: GlyphSnapshot): GlyphSnapshot { + return { + ...a, + xAdvance: a.xAdvance - b.xAdvance, + contours: subContours(a.contours, b.contours), + }; +} + +/** Add snapshot B to A: A + B */ +function addSnapshot(a: GlyphSnapshot, b: GlyphSnapshot): GlyphSnapshot { + return { + ...a, + xAdvance: a.xAdvance + b.xAdvance, + contours: addContours(a.contours, b.contours), + }; +} + +/** Multiply all coordinates in a snapshot by a scalar */ +function mulScalarSnapshot(snap: GlyphSnapshot, s: number): GlyphSnapshot { + if (s === 1) return snap; + return { + ...snap, + xAdvance: snap.xAdvance * s, + contours: mulScalarContours(snap.contours, s), + }; +} + +/** A zero-valued snapshot with the same structure as the reference. */ +function zeroSnapshot(ref: GlyphSnapshot): GlyphSnapshot { + return mulScalarSnapshot(ref, 0); +} + // ── OpenType VariationModel (ported from Fontra/fonttools) ────────── function supportScalar(location: SparseLocation, support: Support): number { @@ -161,7 +260,6 @@ function buildVariationModel( masterLocations: SparseLocation[], axisOrder: string[], ): VariationModelData { - // Sort locations using fonttools' decorated sort const axisPoints: Record> = {}; for (const loc of masterLocations) { const keys = Object.keys(loc); @@ -212,7 +310,6 @@ function buildVariationModel( for (let i = 0; i < regions.length; i++) { const region = { ...regions[i] }; - // Deep-copy the region's arrays for (const axis in region) { region[axis] = [...region[axis]]; } @@ -232,7 +329,6 @@ function buildVariationModel( } if (!relevant) continue; - // Split box let bestAxes: Record = {}; let bestRatio = -1; for (const axis of Object.keys(prevRegion)) { @@ -262,7 +358,6 @@ function buildVariationModel( supports.push(region); } - // Compute delta weights const deltaWeights: Map[] = []; for (let i = 0; i < sortedLocations.length; i++) { const loc = sortedLocations[i]; @@ -277,76 +372,29 @@ function buildVariationModel( return { mapping, reverseMapping, supports, deltaWeights }; } -// ── Flat array helpers for point data ─────────────────────────────── - -/** Flatten a snapshot's point coordinates + xAdvance into a single number[]. */ -function snapshotToFlat(snapshot: GlyphSnapshot): number[] { - const flat: number[] = [snapshot.xAdvance]; - for (const contour of snapshot.contours) { - for (const pt of contour.points) { - flat.push(pt.x, pt.y); - } - } - return flat; -} - -function flatSub(a: number[], b: number[]): number[] { - const r: number[] = Array.from({ length: a.length }); - for (let i = 0; i < a.length; i++) r[i] = a[i] - b[i]; - return r; -} - -function flatMulScalar(a: number[], s: number): number[] { - const r: number[] = Array.from({ length: a.length }); - for (let i = 0; i < a.length; i++) r[i] = a[i] * s; - return r; -} - -function flatAdd(a: number[], b: number[]): number[] { - const r: number[] = Array.from({ length: a.length }); - for (let i = 0; i < a.length; i++) r[i] = a[i] + b[i]; - return r; -} - -/** Reconstruct a GlyphSnapshot from a flat array, using a reference for structure. */ -function flatToSnapshot(flat: number[], ref: GlyphSnapshot): GlyphSnapshot { - let idx = 0; - const xAdvance = flat[idx++]; - - const contours: ContourSnapshot[] = ref.contours.map((refContour) => ({ - id: refContour.id, - closed: refContour.closed, - points: refContour.points.map((refPt) => { - const x = flat[idx++]; - const y = flat[idx++]; - return { ...refPt, x, y } as PointSnapshot; - }), - })); - - return { ...ref, xAdvance, contours }; -} +// ── Public API ────────────────────────────────────────────────────── -function contourSignature(snapshot: GlyphSnapshot): string { - return `${snapshot.contours.length}:${snapshot.contours.map((c) => c.points.length).join(",")}`; +export interface InterpolationResult { + instance: GlyphSnapshot; + /** Sources that were incompatible and excluded from interpolation. */ + errors: SourceError[]; } -// ── Public API ────────────────────────────────────────────────────── - /** * Interpolate a glyph at a target design-space location using the * OpenType VariationModel algorithm (support regions + delta decomposition). * - * Follows Fontra's approach: use the default master as the compatibility - * reference, filter out incompatible sources, and build the VariationModel - * with the compatible subset. The default master is always included. + * Follows Fontra's approach: build the model with ALL sources, then handle + * incompatibility per-source during delta computation. Incompatible sources + * get a zero delta (no contribution) and are reported in `errors`. */ export function interpolateGlyph( masters: MasterSnapshot[], axes: Axis[], target: Record, -): GlyphSnapshot | null { +): InterpolationResult | null { if (masters.length === 0) return null; - if (masters.length === 1) return masters[0].snapshot; + if (masters.length === 1) return { instance: masters[0].snapshot, errors: [] }; // Normalize master locations (sparse: omit axes at default) const normalizedLocations = masters.map((m) => normalizeLocation(m.location.values, axes)); @@ -364,49 +412,53 @@ export function interpolateGlyph( const uniqueMasters = uniqueIndices.map((i) => masters[i]); const uniqueLocations = uniqueIndices.map((i) => normalizedLocations[i]); - if (uniqueMasters.length < 2) return uniqueMasters[0]?.snapshot ?? null; - - // Find the default master (at normalized location {}) - const defaultIdx = uniqueLocations.findIndex((loc) => Object.keys(loc).length === 0); - if (defaultIdx < 0) return null; - - // Use the default master as the compatibility reference (matches Fontra). - // Filter out masters whose contour structure doesn't match the default. - const ref = uniqueMasters[defaultIdx].snapshot; - const refSig = contourSignature(ref); - - const compatIndices = uniqueMasters - .map((m, i) => (contourSignature(m.snapshot) === refSig ? i : -1)) - .filter((i) => i >= 0); - - const compatMasters = compatIndices.map((i) => uniqueMasters[i]); - const compatLocations = compatIndices.map((i) => uniqueLocations[i]); + if (uniqueMasters.length < 2) { + return { instance: uniqueMasters[0]?.snapshot ?? masters[0].snapshot, errors: [] }; + } - if (compatMasters.length < 2) return compatMasters[0]?.snapshot ?? null; + // The VariationModel requires a default location ({}) + const hasDefault = uniqueLocations.some((loc) => Object.keys(loc).length === 0); + if (!hasDefault) return null; const axisOrder = axes.map((a) => a.tag); - // Build model — wrap in try/catch for robustness let model: VariationModelData; try { - model = buildVariationModel(compatLocations, axisOrder); + model = buildVariationModel(uniqueLocations, axisOrder); } catch { return null; } - // Flatten master values into number arrays - const masterFlats = compatMasters.map((m) => snapshotToFlat(m.snapshot)); - - // Compute deltas - const deltas: number[][] = []; - for (let i = 0; i < masterFlats.length; i++) { - let delta = masterFlats[model.reverseMapping[i]]; - const weights = model.deltaWeights[i]; - for (const [j, weight] of weights.entries()) { - delta = - weight === 1 ? flatSub(delta, deltas[j]) : flatSub(delta, flatMulScalar(deltas[j], weight)); + // Find the default master's snapshot (for zero-value reference) + const defaultIdx = uniqueLocations.findIndex((loc) => Object.keys(loc).length === 0); + const defaultSnapshot = uniqueMasters[defaultIdx].snapshot; + + // Compute deltas with per-source error handling. + // If subSnapshot throws for a source, that source gets a zero delta. + const errors: SourceError[] = []; + const deltas: GlyphSnapshot[] = []; + + for (let i = 0; i < uniqueMasters.length; i++) { + const masterValue = uniqueMasters[model.reverseMapping[i]].snapshot; + + try { + let delta = masterValue; + const weights = model.deltaWeights[i]; + for (const [j, weight] of weights.entries()) { + const prev = weight === 1 ? deltas[j] : mulScalarSnapshot(deltas[j], weight); + delta = subSnapshot(delta, prev); + } + deltas.push(delta); + } catch (e) { + // This source is incompatible — zero delta, no contribution. + const originalIdx = model.reverseMapping[i]; + errors.push({ + sourceIndex: originalIdx, + sourceName: uniqueMasters[originalIdx].sourceName, + message: e instanceof Error ? e.message : String(e), + }); + deltas.push(zeroSnapshot(defaultSnapshot)); } - deltas.push(delta); } // Compute scalars at target location @@ -414,14 +466,15 @@ export function interpolateGlyph( const scalars = model.supports.map((support) => supportScalar(normalizedTarget, support)); // Interpolate: sum delta[i] * scalar[i] - let result: number[] | null = null; + let result: GlyphSnapshot | null = null; for (let i = 0; i < scalars.length; i++) { if (!scalars[i]) continue; - const contribution = flatMulScalar(deltas[i], scalars[i]); - result = result === null ? contribution : flatAdd(result, contribution); + const contribution = mulScalarSnapshot(deltas[i], scalars[i]); + result = result === null ? contribution : addSnapshot(result, contribution); } - if (!result) return compatMasters[0].snapshot; - - return flatToSnapshot(result, compatMasters[0].snapshot); + return { + instance: result ?? defaultSnapshot, + errors, + }; } From 50f1155f79688297ee5441d720caa461f5769603 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 20:21:55 +0100 Subject: [PATCH 24/41] Wire variation sliders to glyph grid preview GlyphPreview reacts to a $variationLocation signal on FontEngine. When VariationPanel sets a location via slider/master button, each visible GlyphPreview interpolates its own glyph and renders the interpolated SVG path. Only visible cells compute thanks to React virtualizer. Adds snapshotToSvgPath for TS-side SVG path generation. Also replaces flat-array interpolation with structured itemwise arithmetic matching Fontra's per-source error handling approach. interpolateGlyph returns InterpolationResult { instance, errors }. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../renderer/src/components/GlyphPreview.tsx | 32 ++++- .../src/components/VariationPanel.tsx | 3 + .../src/renderer/src/lib/interpolation/svg.ts | 121 ++++++++++++++++++ .../src/renderer/src/lib/model/Font.ts | 35 ++++- 4 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/renderer/src/lib/interpolation/svg.ts diff --git a/apps/desktop/src/renderer/src/components/GlyphPreview.tsx b/apps/desktop/src/renderer/src/components/GlyphPreview.tsx index 3d3b74dd..6c21e941 100644 --- a/apps/desktop/src/renderer/src/components/GlyphPreview.tsx +++ b/apps/desktop/src/renderer/src/components/GlyphPreview.tsx @@ -1,6 +1,9 @@ -import { memo } from "react"; +import { memo, useMemo } from "react"; import type { FontMetrics } from "@shift/types"; import type { Font } from "@/lib/model/Font"; +import { useSignalState } from "@/lib/reactive"; +import { interpolateGlyph } from "@/lib/interpolation/interpolate"; +import { snapshotToSvgPath } from "@/lib/interpolation/svg"; export const CELL_HEIGHT = 75; @@ -60,8 +63,31 @@ export const GlyphPreview = memo(function GlyphPreview({ return null; } - const svgPath = font.getSvgPath(name); - const advance = font.getAdvance(name); + const variationLocation = useSignalState(font.$variationLocation); + + const interpolated = useMemo(() => { + if (!variationLocation || !font.isVariable()) return null; + + const masters = font.getGlyphMasterSnapshots(name); + if (!masters || masters.length < 2) return null; + + const axes = font.getAxes(); + const target: Record = {}; + for (const axis of axes) { + target[axis.tag] = variationLocation.values[axis.tag] ?? axis.default; + } + + const result = interpolateGlyph(masters, axes, target); + if (!result) return null; + + return { + path: snapshotToSvgPath(result.instance), + advance: result.instance.xAdvance, + }; + }, [variationLocation, name, font]); + + const svgPath = interpolated?.path ?? font.getSvgPath(name); + const advance = interpolated?.advance ?? font.getAdvance(name); const fontMetrics = font.getMetrics(); const cellWidth = computeCellWidth(fontMetrics, advance, height); const containerStyle = { width: cellWidth, height }; diff --git a/apps/desktop/src/renderer/src/components/VariationPanel.tsx b/apps/desktop/src/renderer/src/components/VariationPanel.tsx index d0bce17c..df051483 100644 --- a/apps/desktop/src/renderer/src/components/VariationPanel.tsx +++ b/apps/desktop/src/renderer/src/components/VariationPanel.tsx @@ -62,6 +62,7 @@ export const VariationPanel = () => { setIsInterpolating(true); engine.emitGlyph(result.instance); + engine.setVariationLocation({ values: newLocation }); }, [location, axes, engine], ); @@ -82,6 +83,7 @@ export const VariationPanel = () => { setIsInterpolating(true); engine.emitGlyph(master.snapshot); + engine.setVariationLocation({ values: newLocation }); }, [axes, engine], ); @@ -90,6 +92,7 @@ export const VariationPanel = () => { if (!isInterpolating) return; setIsInterpolating(false); + engine.setVariationLocation(null); const sessionGlyph = engine.getSessionGlyph(); if (sessionGlyph) { engine.emitGlyph(sessionGlyph); diff --git a/apps/desktop/src/renderer/src/lib/interpolation/svg.ts b/apps/desktop/src/renderer/src/lib/interpolation/svg.ts new file mode 100644 index 00000000..a09ade41 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/interpolation/svg.ts @@ -0,0 +1,121 @@ +import type { GlyphSnapshot, PointSnapshot } from "@shift/types"; +import { Validate } from "@shift/validation"; + +/** + * Convert a GlyphSnapshot's contours into an SVG path `d` attribute string. + * Handles line, quadratic, and cubic segments based on point types. + */ +export function snapshotToSvgPath(snapshot: GlyphSnapshot): string { + const parts: string[] = []; + + for (const contour of snapshot.contours) { + const d = contourToSvgD(contour.points, contour.closed); + if (d) parts.push(d); + } + + return parts.join(" "); +} + +function contourToSvgD(points: readonly PointSnapshot[], closed: boolean): string { + if (points.length < 2) return ""; + + const segments = buildSegments(points, closed); + const cmds: string[] = []; + let first = true; + + for (const seg of segments) { + switch (seg.type) { + case "line": { + if (first) { + cmds.push(`M ${fmt(seg.p1.x)} ${fmt(seg.p1.y)}`); + first = false; + } + cmds.push(`L ${fmt(seg.p2.x)} ${fmt(seg.p2.y)}`); + break; + } + case "quad": { + if (first) { + cmds.push(`M ${fmt(seg.p1.x)} ${fmt(seg.p1.y)}`); + first = false; + } + cmds.push(`Q ${fmt(seg.cp.x)} ${fmt(seg.cp.y)} ${fmt(seg.p2.x)} ${fmt(seg.p2.y)}`); + break; + } + case "cubic": { + if (first) { + cmds.push(`M ${fmt(seg.p1.x)} ${fmt(seg.p1.y)}`); + first = false; + } + cmds.push( + `C ${fmt(seg.cp1.x)} ${fmt(seg.cp1.y)} ${fmt(seg.cp2.x)} ${fmt(seg.cp2.y)} ${fmt(seg.p2.x)} ${fmt(seg.p2.y)}`, + ); + break; + } + } + } + + if (closed && cmds.length > 0) cmds.push("Z"); + + return cmds.join(" "); +} + +function fmt(n: number): string { + return Math.round(n * 100) / 100 + ""; +} + +type Coord = { x: number; y: number }; +type Segment = + | { type: "line"; p1: Coord; p2: Coord } + | { type: "quad"; p1: Coord; cp: Coord; p2: Coord } + | { type: "cubic"; p1: Coord; cp1: Coord; cp2: Coord; p2: Coord }; + +function buildSegments(points: readonly PointSnapshot[], closed: boolean): Segment[] { + const segments: Segment[] = []; + const n = points.length; + if (n < 2) return segments; + + // Walk through points collecting on-curve to on-curve segments + // Off-curve points between two on-curves form the control points + const allPoints = closed ? [...points, points[0]] : points; + let i = 0; + + while (i < allPoints.length - 1) { + const start = allPoints[i]; + + if (Validate.isOffCurve(start)) { + i++; + continue; + } + + // Collect off-curve points until next on-curve + const offCurves: PointSnapshot[] = []; + let j = i + 1; + while (j < allPoints.length && Validate.isOffCurve(allPoints[j])) { + offCurves.push(allPoints[j]); + j++; + } + + if (j >= allPoints.length) break; + const end = allPoints[j]; + + switch (offCurves.length) { + case 0: + segments.push({ type: "line", p1: start, p2: end }); + break; + case 1: + segments.push({ type: "quad", p1: start, cp: offCurves[0], p2: end }); + break; + case 2: + segments.push({ type: "cubic", p1: start, cp1: offCurves[0], cp2: offCurves[1], p2: end }); + break; + default: + // Multiple off-curves: treat as cubic with first two + segments.push({ type: "cubic", p1: start, cp1: offCurves[0], cp2: offCurves[1], p2: end }); + break; + } + + i = j; + } + + return segments; +} diff --git a/apps/desktop/src/renderer/src/lib/model/Font.ts b/apps/desktop/src/renderer/src/lib/model/Font.ts index 48287038..3402e44e 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.ts @@ -1,4 +1,5 @@ -import type { FontMetrics, FontMetadata, CompositeGlyph } from "@shift/types"; +import type { FontMetrics, FontMetadata, CompositeGlyph, Axis, Source, Location } from "@shift/types"; +import type { MasterSnapshot } from "@/lib/interpolation/interpolate"; import type { Bounds } from "@shift/geo"; import { signal, type WritableSignal, type Signal } from "@/lib/reactive/signal"; import type { NativeBridge } from "@/bridge"; @@ -15,12 +16,14 @@ export class Font { readonly #$loaded: WritableSignal; readonly #$unicodes: WritableSignal; readonly #$metrics: WritableSignal; + readonly #$variationLocation: WritableSignal; constructor(bridge: NativeBridge) { this.#bridge = bridge; this.#$loaded = signal(false); this.#$unicodes = signal([]); this.#$metrics = signal(null); + this.#$variationLocation = signal(null); } /** @knipclassignore */ @@ -93,6 +96,36 @@ export class Font { return this.#bridge.getSvgPath(name); } + /** @knipclassignore — used by GlyphPreview for variation interpolation */ + get $variationLocation(): Signal { + return this.#$variationLocation; + } + + /** @knipclassignore — used by VariationPanel */ + setVariationLocation(location: Location | null): void { + this.#$variationLocation.set(location); + } + + /** @knipclassignore — used by VariationPanel component */ + isVariable(): boolean { + return this.#bridge.isVariable(); + } + + /** @knipclassignore — used by VariationPanel component */ + getAxes(): Axis[] { + return this.#bridge.getAxes(); + } + + /** @knipclassignore — used by VariationPanel component */ + getSources(): Source[] { + return this.#bridge.getSources(); + } + + /** @knipclassignore — used by VariationPanel component */ + getGlyphMasterSnapshots(glyphName: string): MasterSnapshot[] | null { + return this.#bridge.getGlyphMasterSnapshots(glyphName); + } + composites(glyphName: string): CompositeGlyph | null { return this.#bridge.getGlyphCompositeComponents(glyphName) as CompositeGlyph | null; } From ba25ad07946e985e8b7aa08c8c51b5dc45b9f0eb Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 6 Apr 2026 20:34:21 +0100 Subject: [PATCH 25/41] Use IndexMap for GlyphLayer contours to preserve insertion order HashMap iteration order is non-deterministic, causing contours to be shuffled between masters loaded from separate UFOs. This broke interpolation by blending wrong contours (e.g. inner counter with outer outline). IndexMap preserves insertion order from the UFO file, so contours are consistently ordered across all masters. Removes the sort-by-point-count hack from getGlyphMasterSnapshots since contour order is now correct by construction. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + crates/shift-ir/Cargo.toml | 1 + crates/shift-ir/src/glyph.rs | 7 ++++--- crates/shift-node/src/font_engine.rs | 15 +-------------- 4 files changed, 7 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8b7bddc7..f85b952a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1601,6 +1601,7 @@ dependencies = [ name = "shift-ir" version = "0.1.0" dependencies = [ + "indexmap", "kurbo 0.13.0", "linesweeper", "serde", diff --git a/crates/shift-ir/Cargo.toml b/crates/shift-ir/Cargo.toml index b3adb4a7..c44e7e03 100644 --- a/crates/shift-ir/Cargo.toml +++ b/crates/shift-ir/Cargo.toml @@ -9,6 +9,7 @@ license = "MIT OR Apache-2.0" crate-type = ["rlib"] [dependencies] +indexmap = { version = "2", features = ["serde"] } kurbo = "0.13.0" linesweeper = "0.3.0" serde = { version = "1.0", features = ["derive"] } diff --git a/crates/shift-ir/src/glyph.rs b/crates/shift-ir/src/glyph.rs index 28f05fd5..6363c0e4 100644 --- a/crates/shift-ir/src/glyph.rs +++ b/crates/shift-ir/src/glyph.rs @@ -5,6 +5,7 @@ use crate::entity::{AnchorId, ComponentId, ContourId, GlyphId, LayerId}; use crate::guideline::Guideline; use crate::lib_data::LibData; use crate::GlyphName; +use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -21,7 +22,7 @@ pub struct Glyph { pub struct GlyphLayer { width: f64, height: Option, - contours: HashMap, + contours: IndexMap, components: HashMap, anchors: Vec, guidelines: Vec, @@ -56,7 +57,7 @@ impl GlyphLayer { self.height = height; } - pub fn contours(&self) -> &HashMap { + pub fn contours(&self) -> &IndexMap { &self.contours } @@ -79,7 +80,7 @@ impl GlyphLayer { } pub fn remove_contour(&mut self, id: ContourId) -> Option { - self.contours.remove(&id) + self.contours.shift_remove(&id) } pub fn clear_contours(&mut self) { diff --git a/crates/shift-node/src/font_engine.rs b/crates/shift-node/src/font_engine.rs index 5de0a702..5ddb5bfd 100644 --- a/crates/shift-node/src/font_engine.rs +++ b/crates/shift-node/src/font_engine.rs @@ -583,26 +583,13 @@ impl FontEngine { let primary_unicode = glyph.primary_unicode().unwrap_or(0); - let mut contours: Vec<_> = layer + let contours: Vec<_> = layer .contours() .values() .filter(|c| !c.points().is_empty()) .map(shift_core::snapshot::ContourSnapshot::from) .collect(); - // Sort contours deterministically — ContourIds differ between masters - // loaded from separate UFOs, so HashMap order is random. - contours.sort_by(|a, b| { - let len_cmp = a.points.len().cmp(&b.points.len()).reverse(); - if len_cmp != std::cmp::Ordering::Equal { - return len_cmp; - } - // Tiebreak: first point coordinates - let a_pt = a.points.first().map(|p| (p.x.to_bits(), p.y.to_bits())); - let b_pt = b.points.first().map(|p| (p.x.to_bits(), p.y.to_bits())); - a_pt.cmp(&b_pt) - }); - let anchors = layer .anchors_iter() .map(shift_core::snapshot::AnchorSnapshot::from) From 861120825488deb85f5b59082834983798e8083a Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sat, 11 Apr 2026 11:39:22 +0100 Subject: [PATCH 26/41] Add Rust-native variable font interpolation via VariationModel Implements the fontTools VariationModel algorithm in shift-core for designspace interpolation without external dependencies. This provides a single source of truth for interpolation in Rust, enabling the future preview panel to interpolate and compile in one pass without round-tripping through TypeScript. - interpolation.rs: VariationModel with support regions, delta decomposition, and per-source error handling (17 unit tests) - MasterSnapshot struct in snapshot.rs for shared use across NAPI methods - FontEngine: interpolateGlyph NAPI method + refactored getGlyphMasterSnapshots to use shared build_master_snapshots - TS InterpolationResult type for consuming Rust interpolation results Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/renderer/src/bridge/NativeBridge.ts | 15 + crates/shift-core/src/interpolation.rs | 838 ++++++++++++++++++ crates/shift-core/src/lib.rs | 1 + crates/shift-core/src/snapshot.rs | 13 +- crates/shift-node/index.d.ts | 5 +- crates/shift-node/src/font_engine.rs | 37 +- 6 files changed, 887 insertions(+), 22 deletions(-) create mode 100644 crates/shift-core/src/interpolation.rs diff --git a/apps/desktop/src/renderer/src/bridge/NativeBridge.ts b/apps/desktop/src/renderer/src/bridge/NativeBridge.ts index d6d8cde2..aea8a57f 100644 --- a/apps/desktop/src/renderer/src/bridge/NativeBridge.ts +++ b/apps/desktop/src/renderer/src/bridge/NativeBridge.ts @@ -28,6 +28,11 @@ import { ContourContent } from "@/lib/clipboard"; import type { NodePositionUpdateList } from "@/types/positionUpdate"; import { Glyph } from "@/lib/model/Glyph"; +export interface InterpolationResult { + instance: GlyphSnapshot; + errors: Array<{ sourceIndex: number; sourceName: string; message: string }>; +} + /** * Owns the raw NAPI bridge and the reactive {@link $glyph} signal. * All font queries, session lifecycle, and glyph mutations live here. @@ -163,6 +168,16 @@ export class NativeBridge { return JSON.parse(json) as MasterSnapshot[]; } + /** @knipclassignore — interpolate a glyph at a designspace location in Rust */ + interpolateGlyph( + glyphName: string, + location: Record, + ): InterpolationResult | null { + const json = this.#raw.interpolateGlyph(glyphName, JSON.stringify({ values: location })); + if (!json) return null; + return JSON.parse(json) as InterpolationResult; + } + getSnapshot(): GlyphSnapshot { return JSON.parse(this.#raw.getSnapshotData()) as GlyphSnapshot; } diff --git a/crates/shift-core/src/interpolation.rs b/crates/shift-core/src/interpolation.rs new file mode 100644 index 00000000..9ee5e536 --- /dev/null +++ b/crates/shift-core/src/interpolation.rs @@ -0,0 +1,838 @@ +use std::collections::{HashMap, HashSet}; + +use serde::{Deserialize, Serialize}; + +use crate::snapshot::{GlyphSnapshot, MasterSnapshot}; +use crate::{Axis, Location}; + +type SparseLocation = HashMap; +type Support = HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InterpolationResult { + pub instance: GlyphSnapshot, + pub errors: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SourceError { + pub source_index: usize, + pub source_name: String, + pub message: String, +} + +struct VariationModelData { + mapping: Vec, + supports: Vec, + delta_weights: Vec>, +} + +// --- Normalization --- + +fn normalize_axis_value(value: f64, axis: &Axis) -> f64 { + if value < axis.default() { + let range = axis.default() - axis.minimum(); + if range.abs() < f64::EPSILON { + return 0.0; + } + (value - axis.default()) / range + } else if value > axis.default() { + let range = axis.maximum() - axis.default(); + if range.abs() < f64::EPSILON { + return 0.0; + } + (value - axis.default()) / range + } else { + 0.0 + } +} + +fn normalize_location(location: &Location, axes: &[Axis]) -> SparseLocation { + let mut result = SparseLocation::new(); + for axis in axes { + let value = location.get(axis.tag()).unwrap_or(axis.default()); + let n = normalize_axis_value(value, axis); + if n.abs() > 1e-14 { + result.insert(axis.tag().to_string(), n); + } + } + result +} + +// --- Support scalar --- + +fn support_scalar(location: &SparseLocation, support: &Support) -> f64 { + let mut scalar = 1.0; + for (tag, &(lower, peak, upper)) in support { + let loc_val = location.get(tag).copied().unwrap_or(0.0); + if peak.abs() < f64::EPSILON { + if loc_val.abs() > f64::EPSILON { + return 0.0; + } + continue; + } + if loc_val < lower || loc_val > upper { + return 0.0; + } + if (loc_val - peak).abs() < f64::EPSILON { + continue; + } + if loc_val < peak { + if (peak - lower).abs() < f64::EPSILON { + return 0.0; + } + scalar *= (loc_val - lower) / (peak - lower); + } else { + if (upper - peak).abs() < f64::EPSILON { + return 0.0; + } + scalar *= (upper - loc_val) / (upper - peak); + } + } + scalar +} + +// --- VariationModel construction --- +// Ported from fontTools varLib.models.VariationModel._supports() + +/// Build support regions for each master location. +/// +/// Each support is a sparse map of axis tag → (lower, peak, upper) tent. +/// Only axes where the master deviates from the default (peak != 0) are included. +/// The default master (at origin) has an EMPTY support, giving scalar 1.0 everywhere. +fn build_supports(sorted_locations: &[SparseLocation]) -> Vec { + let mut supports = Vec::with_capacity(sorted_locations.len()); + + for (i, loc) in sorted_locations.iter().enumerate() { + let mut min_v: HashMap = HashMap::new(); + let mut max_v: HashMap = HashMap::new(); + + // Look at previous locations that share the same set of axes + for prev_loc in &sorted_locations[..i] { + let loc_keys: HashSet<&String> = loc.keys().collect(); + let prev_keys: HashSet<&String> = prev_loc.keys().collect(); + if loc_keys != prev_keys { + continue; + } + + for (axis, &val) in prev_loc { + let loc_val = loc.get(axis).copied().unwrap_or(0.0); + // Only consider previous locations on the same side of origin + if val * loc_val > 0.0 { + if val > loc_val { + let entry = min_v.entry(axis.clone()).or_insert(val); + if val < *entry { + *entry = val; + } + } else if val < loc_val { + let entry = max_v.entry(axis.clone()).or_insert(val); + if val > *entry { + *entry = val; + } + } + } + } + } + + let mut support = Support::new(); + for (axis, &val) in loc { + let (lower, upper) = if val > 0.0 { + ( + *min_v.get(axis).unwrap_or(&0.0), + *max_v.get(axis).unwrap_or(&val), + ) + } else { + ( + *min_v.get(axis).unwrap_or(&val), + *max_v.get(axis).unwrap_or(&0.0), + ) + }; + support.insert(axis.clone(), (lower, val, upper)); + } + + supports.push(support); + } + + supports +} + +fn build_variation_model( + locations: &[SparseLocation], + axis_order: &[String], +) -> VariationModelData { + let n = locations.len(); + + let all_axis_points: HashMap> = { + let mut map: HashMap> = HashMap::new(); + for loc in locations { + for (tag, &val) in loc { + map.entry(tag.clone()) + .or_default() + .insert((val * 1e9) as i64); + } + } + map + }; + + let axis_index = |tag: &str| -> usize { + axis_order + .iter() + .position(|t| t == tag) + .unwrap_or(usize::MAX) + }; + + type SortKey = (usize, usize, usize, Vec<(usize, i64, i64)>); + + // Decorate each location for sorting + let mut decorated: Vec = locations + .iter() + .enumerate() + .map(|(orig_idx, loc)| { + let rank = loc.len(); + let on_point_axes = loc + .iter() + .filter(|(tag, _)| all_axis_points.get(*tag).is_some_and(|pts| pts.len() == 1)) + .count(); + + let mut axis_keys: Vec<(usize, i64, i64)> = loc + .iter() + .map(|(tag, &val)| { + let idx = axis_index(tag); + let sign = if val > 0.0 { 0 } else { 1 }; + let magnitude = (val.abs() * 1e9) as i64; + (idx, sign, magnitude) + }) + .collect(); + axis_keys.sort(); + + (orig_idx, rank, on_point_axes, axis_keys) + }) + .collect(); + + decorated.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.cmp(&b.2)).then(a.3.cmp(&b.3))); + + let mapping: Vec = decorated.iter().map(|(orig, _, _, _)| *orig).collect(); + let sorted_locations: Vec = + mapping.iter().map(|&i| locations[i].clone()).collect(); + + let supports = build_supports(&sorted_locations); + + // Compute delta weights + let delta_weights: Vec> = (0..n) + .map(|i| { + let mut weights = Vec::new(); + for (j, support) in supports.iter().enumerate().take(i) { + let scalar = support_scalar(&sorted_locations[i], support); + if scalar.abs() > f64::EPSILON { + weights.push((j, scalar)); + } + } + weights + }) + .collect(); + + VariationModelData { + mapping, + supports, + delta_weights, + } +} + +// --- Snapshot flattening --- + +fn flatten_snapshot(snap: &GlyphSnapshot) -> Vec { + let mut values = vec![snap.x_advance]; + for contour in &snap.contours { + for point in &contour.points { + values.push(point.x); + values.push(point.y); + } + } + for anchor in &snap.anchors { + values.push(anchor.x); + values.push(anchor.y); + } + values +} + +fn reconstruct_snapshot(template: &GlyphSnapshot, values: &[f64]) -> GlyphSnapshot { + let mut result = template.clone(); + let mut idx = 0; + + result.x_advance = values[idx]; + idx += 1; + + for contour in &mut result.contours { + for point in &mut contour.points { + point.x = values[idx]; + idx += 1; + point.y = values[idx]; + idx += 1; + } + } + + for anchor in &mut result.anchors { + anchor.x = values[idx]; + idx += 1; + anchor.y = values[idx]; + idx += 1; + } + + result +} + +fn check_compatibility(a: &GlyphSnapshot, b: &GlyphSnapshot) -> Result<(), String> { + if a.contours.len() != b.contours.len() { + return Err(format!( + "contour count mismatch: {} vs {}", + a.contours.len(), + b.contours.len() + )); + } + for (i, (ca, cb)) in a.contours.iter().zip(b.contours.iter()).enumerate() { + if ca.points.len() != cb.points.len() { + return Err(format!( + "contour {} point count mismatch: {} vs {}", + i, + ca.points.len(), + cb.points.len() + )); + } + } + if a.anchors.len() != b.anchors.len() { + return Err(format!( + "anchor count mismatch: {} vs {}", + a.anchors.len(), + b.anchors.len() + )); + } + Ok(()) +} + +// --- Itemwise arithmetic --- + +fn sub_values(a: &[f64], b: &[f64]) -> Vec { + a.iter().zip(b.iter()).map(|(x, y)| x - y).collect() +} + +fn add_values(a: &[f64], b: &[f64]) -> Vec { + a.iter().zip(b.iter()).map(|(x, y)| x + y).collect() +} + +fn mul_scalar_values(a: &[f64], s: f64) -> Vec { + a.iter().map(|x| x * s).collect() +} + +fn zero_values(len: usize) -> Vec { + vec![0.0; len] +} + +// --- Main entry point --- + +fn location_to_key(loc: &SparseLocation) -> String { + let mut keys: Vec<_> = loc.iter().collect(); + keys.sort_by(|a, b| a.0.cmp(b.0)); + keys.iter() + .map(|(k, v)| format!("{k}:{v:.10}")) + .collect::>() + .join(",") +} + +pub fn interpolate_glyph( + masters: &[MasterSnapshot], + axes: &[Axis], + target: &Location, +) -> Option { + if masters.len() < 2 { + return None; + } + + let axis_order: Vec = axes.iter().map(|a| a.tag().to_string()).collect(); + + // Normalize all master locations + let normalized_masters: Vec<(usize, SparseLocation)> = masters + .iter() + .enumerate() + .map(|(i, m)| (i, normalize_location(&m.location, axes))) + .collect(); + + // Check for a default master (at origin) + let has_default = normalized_masters.iter().any(|(_, loc)| loc.is_empty()); + if !has_default { + return None; + } + + // Deduplicate by normalized location + let mut seen = HashSet::new(); + let deduped: Vec<(usize, SparseLocation)> = normalized_masters + .into_iter() + .filter(|(_, loc)| seen.insert(location_to_key(loc))) + .collect(); + + if deduped.len() < 2 { + return None; + } + + // Find the default master (first at empty location) + let default_idx = deduped.iter().position(|(_, loc)| loc.is_empty()).unwrap(); + let default_master = &masters[deduped[default_idx].0]; + + // Build the variation model from deduplicated locations + let model_locations: Vec = deduped.iter().map(|(_, loc)| loc.clone()).collect(); + let model = build_variation_model(&model_locations, &axis_order); + + // Flatten the default master to get the value length + let default_values = flatten_snapshot(&default_master.snapshot); + let value_len = default_values.len(); + + // Compute deltas for each sorted master + let mut deltas: Vec> = Vec::with_capacity(model.mapping.len()); + let mut errors: Vec = Vec::new(); + + for (sorted_idx, &orig_model_idx) in model.mapping.iter().enumerate() { + let master_idx = deduped[orig_model_idx].0; + let master = &masters[master_idx]; + + // Check compatibility with default + match check_compatibility(&default_master.snapshot, &master.snapshot) { + Ok(()) => { + let master_values = flatten_snapshot(&master.snapshot); + // delta = master_values - sum(delta_weights[j] * deltas[j]) + let mut delta = sub_values(&master_values, &zero_values(value_len)); + for &(prev_sorted, weight) in &model.delta_weights[sorted_idx] { + let contribution = mul_scalar_values(&deltas[prev_sorted], weight); + delta = sub_values(&delta, &contribution); + } + deltas.push(delta); + } + Err(msg) => { + errors.push(SourceError { + source_index: master_idx, + source_name: master.source_name.clone(), + message: msg, + }); + deltas.push(zero_values(value_len)); + } + } + } + + // Interpolate at target location + let target_normalized = normalize_location(target, axes); + let mut result_values = zero_values(value_len); + let mut has_contribution = false; + + for (sorted_idx, support) in model.supports.iter().enumerate() { + let scalar = support_scalar(&target_normalized, support); + if scalar.abs() < f64::EPSILON { + continue; + } + let contribution = mul_scalar_values(&deltas[sorted_idx], scalar); + result_values = add_values(&result_values, &contribution); + has_contribution = true; + } + + if !has_contribution { + return Some(InterpolationResult { + instance: default_master.snapshot.clone(), + errors, + }); + } + + let instance = reconstruct_snapshot(&default_master.snapshot, &result_values); + + Some(InterpolationResult { instance, errors }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::snapshot::{ + AnchorSnapshot, ContourSnapshot, GlyphSnapshot, MasterSnapshot, PointSnapshot, PointType, + }; + use crate::{Axis, Location}; + + fn make_axis(tag: &str, name: &str, min: f64, default: f64, max: f64) -> Axis { + Axis::new(tag.to_string(), name.to_string(), min, default, max) + } + + fn make_point(id: &str, x: f64, y: f64) -> PointSnapshot { + PointSnapshot { + id: id.to_string(), + x, + y, + point_type: PointType::OnCurve, + smooth: false, + } + } + + fn make_contour(id: &str, points: Vec) -> ContourSnapshot { + ContourSnapshot { + id: id.to_string(), + points, + closed: true, + } + } + + fn make_snapshot(contours: Vec, x_advance: f64) -> GlyphSnapshot { + GlyphSnapshot { + unicode: 65, + name: "A".to_string(), + x_advance, + contours, + anchors: Vec::new(), + composite_contours: Vec::new(), + active_contour_id: None, + } + } + + fn make_master(name: &str, location: Location, snapshot: GlyphSnapshot) -> MasterSnapshot { + MasterSnapshot { + source_id: name.to_string(), + source_name: name.to_string(), + location, + snapshot, + } + } + + // --- normalize_axis_value tests --- + + #[test] + fn normalize_returns_zero_at_default() { + let axis = make_axis("wght", "Weight", 100.0, 400.0, 900.0); + assert!((normalize_axis_value(400.0, &axis)).abs() < f64::EPSILON); + } + + #[test] + fn normalize_returns_neg_one_at_min() { + let axis = make_axis("wght", "Weight", 100.0, 400.0, 900.0); + assert!((normalize_axis_value(100.0, &axis) - (-1.0)).abs() < 0.001); + } + + #[test] + fn normalize_returns_one_at_max() { + let axis = make_axis("wght", "Weight", 100.0, 400.0, 900.0); + assert!((normalize_axis_value(900.0, &axis) - 1.0).abs() < 0.001); + } + + #[test] + fn normalize_midpoint() { + let axis = make_axis("wght", "Weight", 100.0, 400.0, 900.0); + assert!((normalize_axis_value(650.0, &axis) - 0.5).abs() < 0.001); + } + + // --- check_compatibility tests --- + + #[test] + fn compatible_masters_pass() { + let a = make_snapshot( + vec![make_contour( + "c1", + vec![make_point("p1", 0.0, 0.0), make_point("p2", 100.0, 0.0)], + )], + 500.0, + ); + let b = make_snapshot( + vec![make_contour( + "c1", + vec![make_point("p1", 0.0, 0.0), make_point("p2", 200.0, 0.0)], + )], + 600.0, + ); + assert!(check_compatibility(&a, &b).is_ok()); + } + + #[test] + fn incompatible_contour_count() { + let a = make_snapshot( + vec![make_contour("c1", vec![make_point("p1", 0.0, 0.0)])], + 500.0, + ); + let b = make_snapshot(Vec::new(), 500.0); + assert!(check_compatibility(&a, &b).is_err()); + } + + #[test] + fn incompatible_point_count() { + let a = make_snapshot( + vec![make_contour( + "c1", + vec![make_point("p1", 0.0, 0.0), make_point("p2", 100.0, 0.0)], + )], + 500.0, + ); + let b = make_snapshot( + vec![make_contour("c1", vec![make_point("p1", 0.0, 0.0)])], + 500.0, + ); + assert!(check_compatibility(&a, &b).is_err()); + } + + // --- interpolate_glyph tests --- + + fn two_master_setup() -> (Vec, Vec) { + let axes = vec![make_axis("wght", "Weight", 0.0, 0.0, 1000.0)]; + + let light = make_master( + "Light", + Location::new(), + make_snapshot( + vec![make_contour( + "c1", + vec![ + make_point("p1", 0.0, 0.0), + make_point("p2", 100.0, 0.0), + make_point("p3", 100.0, 100.0), + ], + )], + 400.0, + ), + ); + + let mut bold_loc = Location::new(); + bold_loc.set("wght".to_string(), 1000.0); + let bold = make_master( + "Bold", + bold_loc, + make_snapshot( + vec![make_contour( + "c1", + vec![ + make_point("p1", 0.0, 0.0), + make_point("p2", 200.0, 0.0), + make_point("p3", 200.0, 200.0), + ], + )], + 600.0, + ), + ); + + (vec![light, bold], axes) + } + + #[test] + fn interpolate_midpoint() { + let (masters, axes) = two_master_setup(); + let mut target = Location::new(); + target.set("wght".to_string(), 500.0); + + let result = interpolate_glyph(&masters, &axes, &target).unwrap(); + + assert!((result.instance.x_advance - 500.0).abs() < 0.01); + assert!((result.instance.contours[0].points[1].x - 150.0).abs() < 0.01); + assert!((result.instance.contours[0].points[2].y - 150.0).abs() < 0.01); + assert!(result.errors.is_empty()); + } + + #[test] + fn interpolate_at_default_returns_default() { + let (masters, axes) = two_master_setup(); + let target = Location::new(); // default = wght 0 + + let result = interpolate_glyph(&masters, &axes, &target).unwrap(); + + assert!((result.instance.x_advance - 400.0).abs() < 0.01); + assert!((result.instance.contours[0].points[1].x - 100.0).abs() < 0.01); + } + + #[test] + fn interpolate_at_master_returns_master() { + let (masters, axes) = two_master_setup(); + let mut target = Location::new(); + target.set("wght".to_string(), 1000.0); + + let result = interpolate_glyph(&masters, &axes, &target).unwrap(); + + assert!((result.instance.x_advance - 600.0).abs() < 0.01); + assert!((result.instance.contours[0].points[1].x - 200.0).abs() < 0.01); + } + + #[test] + fn preserves_point_metadata() { + let (masters, axes) = two_master_setup(); + let mut target = Location::new(); + target.set("wght".to_string(), 500.0); + + let result = interpolate_glyph(&masters, &axes, &target).unwrap(); + + assert_eq!(result.instance.contours[0].points[0].id, "p1"); + assert_eq!(result.instance.contours[0].points[1].id, "p2"); + assert_eq!(result.instance.contours[0].id, "c1"); + assert!(matches!( + result.instance.contours[0].points[0].point_type, + PointType::OnCurve + )); + } + + #[test] + fn two_axis_four_master_interpolation() { + let axes = vec![ + make_axis("wdth", "Width", 0.0, 0.0, 1000.0), + make_axis("wght", "Weight", 0.0, 0.0, 1000.0), + ]; + + let make_single = |x: f64, adv: f64| -> GlyphSnapshot { + make_snapshot( + vec![make_contour("c1", vec![make_point("p1", x, 0.0)])], + adv, + ) + }; + + let m1 = make_master("LightCondensed", Location::new(), make_single(100.0, 400.0)); + + let mut loc2 = Location::new(); + loc2.set("wght".to_string(), 1000.0); + let m2 = make_master("BoldCondensed", loc2, make_single(200.0, 600.0)); + + let mut loc3 = Location::new(); + loc3.set("wdth".to_string(), 1000.0); + let m3 = make_master("LightWide", loc3, make_single(300.0, 800.0)); + + let mut loc4 = Location::new(); + loc4.set("wdth".to_string(), 1000.0); + loc4.set("wght".to_string(), 1000.0); + let m4 = make_master("BoldWide", loc4, make_single(400.0, 1000.0)); + + let masters = vec![m1, m2, m3, m4]; + let mut target = Location::new(); + target.set("wdth".to_string(), 500.0); + target.set("wght".to_string(), 500.0); + + let result = interpolate_glyph(&masters, &axes, &target).unwrap(); + + // Midpoint of all four: (100+200+300+400)/4 = 250 + assert!((result.instance.contours[0].points[0].x - 250.0).abs() < 0.01); + } + + #[test] + fn returns_none_for_single_master() { + let axes = vec![make_axis("wght", "Weight", 0.0, 0.0, 1000.0)]; + let master = make_master("Only", Location::new(), make_snapshot(vec![], 500.0)); + + let result = interpolate_glyph(&[master], &axes, &Location::new()); + assert!(result.is_none()); + } + + #[test] + fn returns_none_without_default_master() { + let axes = vec![make_axis("wght", "Weight", 0.0, 0.0, 1000.0)]; + + let mut loc1 = Location::new(); + loc1.set("wght".to_string(), 500.0); + let mut loc2 = Location::new(); + loc2.set("wght".to_string(), 1000.0); + + let m1 = make_master("A", loc1, make_snapshot(vec![], 500.0)); + let m2 = make_master("B", loc2, make_snapshot(vec![], 600.0)); + + let result = interpolate_glyph(&[m1, m2], &axes, &Location::new()); + assert!(result.is_none()); + } + + #[test] + fn incompatible_source_reports_error() { + let axes = vec![make_axis("wght", "Weight", 0.0, 0.0, 1000.0)]; + + let default_snap = make_snapshot( + vec![make_contour( + "c1", + vec![make_point("p1", 0.0, 0.0), make_point("p2", 100.0, 0.0)], + )], + 400.0, + ); + + // Incompatible: different point count + let bad_snap = make_snapshot( + vec![make_contour("c1", vec![make_point("p1", 0.0, 0.0)])], + 600.0, + ); + + let m1 = make_master("Default", Location::new(), default_snap); + let mut loc2 = Location::new(); + loc2.set("wght".to_string(), 1000.0); + let m2 = make_master("Bad", loc2, bad_snap); + + let mut target = Location::new(); + target.set("wght".to_string(), 500.0); + + let result = interpolate_glyph(&[m1, m2], &axes, &target).unwrap(); + + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].source_name, "Bad"); + // Should return default since incompatible master is zeroed + assert!((result.instance.x_advance - 400.0).abs() < 0.01); + } + + #[test] + fn deduplicates_same_location() { + let axes = vec![make_axis("wght", "Weight", 0.0, 0.0, 1000.0)]; + + let snap1 = make_snapshot( + vec![make_contour("c1", vec![make_point("p1", 100.0, 0.0)])], + 400.0, + ); + let snap2 = snap1.clone(); + + let mut bold_loc = Location::new(); + bold_loc.set("wght".to_string(), 1000.0); + let snap3 = make_snapshot( + vec![make_contour("c1", vec![make_point("p1", 200.0, 0.0)])], + 600.0, + ); + + let masters = vec![ + make_master("Default1", Location::new(), snap1), + make_master("Default2", Location::new(), snap2), + make_master("Bold", bold_loc, snap3), + ]; + + let mut target = Location::new(); + target.set("wght".to_string(), 500.0); + + let result = interpolate_glyph(&masters, &axes, &target).unwrap(); + assert!((result.instance.contours[0].points[0].x - 150.0).abs() < 0.01); + } + + #[test] + fn interpolates_anchors() { + let axes = vec![make_axis("wght", "Weight", 0.0, 0.0, 1000.0)]; + + let mut snap_light = make_snapshot( + vec![make_contour("c1", vec![make_point("p1", 100.0, 0.0)])], + 400.0, + ); + snap_light.anchors.push(AnchorSnapshot { + id: "a1".to_string(), + name: Some("top".to_string()), + x: 250.0, + y: 700.0, + }); + + let mut snap_bold = make_snapshot( + vec![make_contour("c1", vec![make_point("p1", 200.0, 0.0)])], + 600.0, + ); + snap_bold.anchors.push(AnchorSnapshot { + id: "a1".to_string(), + name: Some("top".to_string()), + x: 300.0, + y: 800.0, + }); + + let mut bold_loc = Location::new(); + bold_loc.set("wght".to_string(), 1000.0); + + let masters = vec![ + make_master("Light", Location::new(), snap_light), + make_master("Bold", bold_loc, snap_bold), + ]; + + let mut target = Location::new(); + target.set("wght".to_string(), 500.0); + + let result = interpolate_glyph(&masters, &axes, &target).unwrap(); + assert!((result.instance.anchors[0].x - 275.0).abs() < 0.01); + assert!((result.instance.anchors[0].y - 750.0).abs() < 0.01); + } +} diff --git a/crates/shift-core/src/lib.rs b/crates/shift-core/src/lib.rs index 6ad9a531..ae75a7dd 100644 --- a/crates/shift-core/src/lib.rs +++ b/crates/shift-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod curve; pub mod dependency_graph; pub mod edit_session; pub mod font_loader; +pub mod interpolation; pub mod snapshot; pub mod vec2; diff --git a/crates/shift-core/src/snapshot.rs b/crates/shift-core/src/snapshot.rs index 4e80a63f..da45da6d 100644 --- a/crates/shift-core/src/snapshot.rs +++ b/crates/shift-core/src/snapshot.rs @@ -1,7 +1,9 @@ use serde::{Deserialize, Serialize}; use ts_rs::TS; -use crate::{edit_session::EditSession, Anchor, Contour, Point, PointId, PointType as IrPointType}; +use crate::{ + edit_session::EditSession, Anchor, Contour, Location, Point, PointId, PointType as IrPointType, +}; #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] @@ -220,6 +222,15 @@ impl CommandResult { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MasterSnapshot { + pub source_id: String, + pub source_name: String, + pub location: Location, + pub snapshot: GlyphSnapshot, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/shift-node/index.d.ts b/crates/shift-node/index.d.ts index 2be53a65..a5746553 100644 --- a/crates/shift-node/index.d.ts +++ b/crates/shift-node/index.d.ts @@ -28,11 +28,8 @@ export declare class FontEngine { isVariable(): boolean getAxes(): string getSources(): string - /** - * Returns a JSON object mapping source IDs to their glyph snapshots, - * including the source location. Used by the TS interpolation engine. - */ getGlyphMasterSnapshots(glyphName: string): string | null + interpolateGlyph(glyphName: string, locationJson: string): string | null startEditSession(glyphRef: JsGlyphRef): void endEditSession(): void hasEditSession(): boolean diff --git a/crates/shift-node/src/font_engine.rs b/crates/shift-node/src/font_engine.rs index 5ddb5bfd..f18c5f22 100644 --- a/crates/shift-node/src/font_engine.rs +++ b/crates/shift-node/src/font_engine.rs @@ -10,7 +10,7 @@ use shift_core::{ dependency_graph::DependencyGraph, edit_session::EditSession, font_loader::FontLoader, - snapshot::{CommandResult, GlyphSnapshot, RenderContourSnapshot}, + snapshot::{CommandResult, GlyphSnapshot, MasterSnapshot, RenderContourSnapshot}, AnchorId, BooleanOp, ContourId, Font, FontWriter, Glyph, GlyphLayer, GuidelineId, LayerId, Location, NodePositionUpdate, NodeRef, PasteContour, PointId, PointType, UfoWriter, }; @@ -536,16 +536,28 @@ impl FontEngine { to_json(&self.font.sources()) } - /// Returns a JSON object mapping source IDs to their glyph snapshots, - /// including the source location. Used by the TS interpolation engine. + /// Returns a JSON array of master snapshots for a glyph. #[napi] pub fn get_glyph_master_snapshots(&self, glyph_name: String) -> Option { + let masters = self.build_master_snapshots(&glyph_name)?; + Some(to_json(&masters)) + } + + /// Interpolate a glyph at a given designspace location. + #[napi] + pub fn interpolate_glyph(&self, glyph_name: String, location_json: String) -> Option { + let masters = self.build_master_snapshots(&glyph_name)?; + let target: Location = serde_json::from_str(&location_json).expect("Invalid location JSON"); + let axes = self.font.axes(); + let result = shift_core::interpolation::interpolate_glyph(&masters, axes, &target)?; + Some(to_json(&result)) + } + + fn build_master_snapshots(&self, glyph_name: &str) -> Option> { if !self.font.is_variable() { return None; } - // The editing glyph is taken out of the font during a session - // (and its default layer moved into the EditSession), so reconstruct it. let mut temp_glyph; let glyph = if let (Some(editing), Some(session), Some(layer_id)) = ( &self.editing_glyph, @@ -557,21 +569,12 @@ impl FontEngine { temp_glyph.set_layer(*layer_id, session.layer().clone()); &temp_glyph } else { - self.font.glyph(&glyph_name)? + self.font.glyph(glyph_name)? } } else { - self.font.glyph(&glyph_name)? + self.font.glyph(glyph_name)? }; - #[derive(serde::Serialize)] - #[serde(rename_all = "camelCase")] - struct MasterSnapshot { - source_id: String, - source_name: String, - location: Location, - snapshot: GlyphSnapshot, - } - let mut masters: Vec = Vec::new(); for source in self.font.sources() { @@ -617,7 +620,7 @@ impl FontEngine { return None; } - Some(to_json(&masters)) + Some(masters) } // ═══════════════════════════════════════════════════════════ From 73540235797ecde81e4bec1970ad96d3b7d7cb2e Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sat, 11 Apr 2026 11:40:36 +0100 Subject: [PATCH 27/41] Switch GlyphPreview to Rust interpolateGlyph for grid perf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the TS interpolation path (getGlyphMasterSnapshots → TS VariationModel → snapshotToSvgPath) with a single NAPI call to interpolateGlyph. This eliminates N serializations of all master snapshot data per slider tick, replacing them with N calls that each return only the single interpolated result. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/renderer/src/components/GlyphPreview.tsx | 11 +++-------- apps/desktop/src/renderer/src/lib/model/Font.ts | 9 +++++++++ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/GlyphPreview.tsx b/apps/desktop/src/renderer/src/components/GlyphPreview.tsx index 6c21e941..030794a7 100644 --- a/apps/desktop/src/renderer/src/components/GlyphPreview.tsx +++ b/apps/desktop/src/renderer/src/components/GlyphPreview.tsx @@ -2,7 +2,6 @@ import { memo, useMemo } from "react"; import type { FontMetrics } from "@shift/types"; import type { Font } from "@/lib/model/Font"; import { useSignalState } from "@/lib/reactive"; -import { interpolateGlyph } from "@/lib/interpolation/interpolate"; import { snapshotToSvgPath } from "@/lib/interpolation/svg"; export const CELL_HEIGHT = 75; @@ -68,16 +67,12 @@ export const GlyphPreview = memo(function GlyphPreview({ const interpolated = useMemo(() => { if (!variationLocation || !font.isVariable()) return null; - const masters = font.getGlyphMasterSnapshots(name); - if (!masters || masters.length < 2) return null; - - const axes = font.getAxes(); const target: Record = {}; - for (const axis of axes) { - target[axis.tag] = variationLocation.values[axis.tag] ?? axis.default; + for (const [tag, value] of Object.entries(variationLocation.values)) { + if (value !== undefined) target[tag] = value; } - const result = interpolateGlyph(masters, axes, target); + const result = font.interpolateGlyph(name, target); if (!result) return null; return { diff --git a/apps/desktop/src/renderer/src/lib/model/Font.ts b/apps/desktop/src/renderer/src/lib/model/Font.ts index 3402e44e..8b87513b 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.ts @@ -1,5 +1,6 @@ import type { FontMetrics, FontMetadata, CompositeGlyph, Axis, Source, Location } from "@shift/types"; import type { MasterSnapshot } from "@/lib/interpolation/interpolate"; +import type { InterpolationResult } from "@/bridge/NativeBridge"; import type { Bounds } from "@shift/geo"; import { signal, type WritableSignal, type Signal } from "@/lib/reactive/signal"; import type { NativeBridge } from "@/bridge"; @@ -126,6 +127,14 @@ export class Font { return this.#bridge.getGlyphMasterSnapshots(glyphName); } + /** @knipclassignore — interpolate a glyph at a designspace location in Rust */ + interpolateGlyph( + glyphName: string, + location: Record, + ): InterpolationResult | null { + return this.#bridge.interpolateGlyph(glyphName, location); + } + composites(glyphName: string): CompositeGlyph | null { return this.#bridge.getGlyphCompositeComponents(glyphName) as CompositeGlyph | null; } From 7540a82e4a5b91dd115b89976c8c7430c7c761ba Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sat, 11 Apr 2026 14:08:00 +0100 Subject: [PATCH 28/41] wip --- crates/shift-node/index.d.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/shift-node/index.d.ts b/crates/shift-node/index.d.ts index a5746553..ed6f7a1a 100644 --- a/crates/shift-node/index.d.ts +++ b/crates/shift-node/index.d.ts @@ -28,7 +28,9 @@ export declare class FontEngine { isVariable(): boolean getAxes(): string getSources(): string + /** Returns a JSON array of master snapshots for a glyph. */ getGlyphMasterSnapshots(glyphName: string): string | null + /** Interpolate a glyph at a given designspace location. */ interpolateGlyph(glyphName: string, locationJson: string): string | null startEditSession(glyphRef: JsGlyphRef): void endEditSession(): void From de9ba1cf37cb46aada8c6127808c604c9a54f245 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 12 Apr 2026 16:36:39 +0100 Subject: [PATCH 29/41] Fix VariationPanel for consolidated Editor/Font API - Use editor.font instead of editor.fontEngine - Use editor.getActiveGlyphName() instead of engine.getEditingGlyphName() - Use glyph.apply(snapshot) instead of engine.emitGlyph() - Remove section divider comments from interpolation module Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/VariationPanel.tsx | 42 +++++++++---------- .../src/renderer/src/lib/editor/Editor.ts | 3 +- .../src/lib/interpolation/interpolate.ts | 9 ---- 3 files changed, 23 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/VariationPanel.tsx b/apps/desktop/src/renderer/src/components/VariationPanel.tsx index df051483..3deeeece 100644 --- a/apps/desktop/src/renderer/src/components/VariationPanel.tsx +++ b/apps/desktop/src/renderer/src/components/VariationPanel.tsx @@ -8,8 +8,8 @@ import { interpolateGlyph, type MasterSnapshot } from "@/lib/interpolation/inter /** Variation axis slider panel — shown when a variable font is loaded. */ export const VariationPanel = () => { const editor = getEditor(); - const engine = editor.fontEngine; - const fontLoaded = useSignalState(engine.$fontLoaded); + const font = editor.font; + const fontLoaded = useSignalState(font.$loaded); const [axes, setAxes] = useState([]); const [location, setLocation] = useState>({}); @@ -17,14 +17,13 @@ export const VariationPanel = () => { const [isInterpolating, setIsInterpolating] = useState(false); const [editingGlyph, setEditingGlyph] = useState(null); - // Load axes when font is loaded useEffect(() => { - if (!fontLoaded || !engine.isVariable()) { + if (!fontLoaded || !font.isVariable()) { setAxes([]); return; } - const fontAxes = engine.getAxes(); + const fontAxes = font.getAxes(); setAxes(fontAxes); const defaults: Record = {}; @@ -32,11 +31,10 @@ export const VariationPanel = () => { defaults[axis.tag] = axis.default; } setLocation(defaults); - }, [fontLoaded, engine]); + }, [fontLoaded, font]); - // Track the current glyph and reload masters when it changes useEffect(() => { - setEditingGlyph(engine.getEditingGlyphName()); + setEditingGlyph(editor.getActiveGlyphName()); }); useEffect(() => { @@ -45,9 +43,9 @@ export const VariationPanel = () => { return; } - mastersRef.current = engine.getGlyphMasterSnapshots(editingGlyph); + mastersRef.current = font.getGlyphMasterSnapshots(editingGlyph); setIsInterpolating(false); - }, [axes, editingGlyph, engine]); + }, [axes, editingGlyph, font]); const handleAxisChange = useCallback( (tag: string, value: number) => { @@ -61,10 +59,11 @@ export const VariationPanel = () => { if (!result) return; setIsInterpolating(true); - engine.emitGlyph(result.instance); - engine.setVariationLocation({ values: newLocation }); + const glyph = editor.glyph.peek(); + if (glyph) glyph.apply(result.instance); + font.setVariationLocation({ values: newLocation }); }, - [location, axes, engine], + [location, axes, editor, font], ); const handleMasterClick = useCallback( @@ -82,20 +81,21 @@ export const VariationPanel = () => { setLocation(newLocation); setIsInterpolating(true); - engine.emitGlyph(master.snapshot); - engine.setVariationLocation({ values: newLocation }); + const glyph = editor.glyph.peek(); + if (glyph) glyph.apply(master.snapshot); + font.setVariationLocation({ values: newLocation }); }, - [axes, engine], + [axes, editor, font], ); const handleResetToSession = useCallback(() => { if (!isInterpolating) return; setIsInterpolating(false); - engine.setVariationLocation(null); - const sessionGlyph = engine.getSessionGlyph(); - if (sessionGlyph) { - engine.emitGlyph(sessionGlyph); + font.setVariationLocation(null); + const glyph = editor.glyph.peek(); + if (glyph) { + glyph.restoreSnapshot(glyph.toSnapshot()); } const defaults: Record = {}; @@ -103,7 +103,7 @@ export const VariationPanel = () => { defaults[axis.tag] = axis.default; } setLocation(defaults); - }, [isInterpolating, engine, axes]); + }, [isInterpolating, editor, font, axes]); if (axes.length === 0) return null; diff --git a/apps/desktop/src/renderer/src/lib/editor/Editor.ts b/apps/desktop/src/renderer/src/lib/editor/Editor.ts index 4e02ade3..9ed3fc43 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Editor.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Editor.ts @@ -281,7 +281,8 @@ export class Editor { this.#drawOffset = signal({ x: 0, y: 0 }); this.#staticEffect = effect(() => { - this.#$glyph.value; + const glyph = this.#$glyph.value; + if (glyph) void glyph.path; // track contour changes via computed path this.#drawOffset.value; this.selection.pointIds; this.selection.anchorIds; diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts index d45980e1..bd58d4fe 100644 --- a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts @@ -26,8 +26,6 @@ export interface SourceError { message: string; } -// ── Axis normalization ────────────────────────────────────────────── - export function normalizeAxisValue(value: number, axis: Axis): number { if (value < axis.default) { const range = axis.default - axis.minimum; @@ -60,8 +58,6 @@ function normalizeLocation( return out; } -// ── Compatibility check ───────────────────────────────────────────── - export function checkCompatibility(masters: MasterSnapshot[]): string | null { if (masters.length < 2) return null; @@ -84,7 +80,6 @@ export function checkCompatibility(masters: MasterSnapshot[]): string | null { return null; } -// ── Itemwise arithmetic on GlyphSnapshot (matches Fontra) ─────────── // // These operate on the structured glyph data directly. If two snapshots // have different contour/point counts, the operation throws — callers @@ -172,8 +167,6 @@ function zeroSnapshot(ref: GlyphSnapshot): GlyphSnapshot { return mulScalarSnapshot(ref, 0); } -// ── OpenType VariationModel (ported from Fontra/fonttools) ────────── - function supportScalar(location: SparseLocation, support: Support): number { let scalar = 1.0; for (const axis in support) { @@ -372,8 +365,6 @@ function buildVariationModel( return { mapping, reverseMapping, supports, deltaWeights }; } -// ── Public API ────────────────────────────────────────────────────── - export interface InterpolationResult { instance: GlyphSnapshot; /** Sources that were incompatible and excluded from interpolation. */ From 0601cce61347cd0ab9234d53805a3a73d83fe288 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 12 Apr 2026 16:51:45 +0100 Subject: [PATCH 30/41] Fix text run: use font.glyphName() for codepoint resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit insertTextCodepoint used font.nameForUnicode() which returns null for glyphs not in the font. Changed to font.glyphName() which falls back to the glyph-info DB and uni${hex} naming — matching the old behavior before the GlyphNamingService was deleted. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/desktop/src/renderer/src/lib/editor/Editor.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/src/lib/editor/Editor.ts b/apps/desktop/src/renderer/src/lib/editor/Editor.ts index 9ed3fc43..2d3d0c9d 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Editor.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Editor.ts @@ -659,8 +659,7 @@ export class Editor { /** Resolve a unicode codepoint to a glyph ref and insert into the text run. */ public insertTextCodepoint(codepoint: number): void { - const glyphName = this.font.nameForUnicode(codepoint); - if (!glyphName) return; + const glyphName = this.font.glyphName(codepoint); this.#textRunController.insert({ glyphName, unicode: codepoint }); } From 57e2bbb22a2eb16686621a966c3a8aa59230f386 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 12 Apr 2026 16:59:34 +0100 Subject: [PATCH 31/41] Round xAdvance display in GlyphSection The interpolated xAdvance is a float (e.g. 821.9096000000001). Round to integer for display. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/renderer/src/components/sidebar-right/GlyphSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/src/components/sidebar-right/GlyphSection.tsx b/apps/desktop/src/renderer/src/components/sidebar-right/GlyphSection.tsx index da52fdf8..5f947209 100644 --- a/apps/desktop/src/renderer/src/components/sidebar-right/GlyphSection.tsx +++ b/apps/desktop/src/renderer/src/components/sidebar-right/GlyphSection.tsx @@ -16,7 +16,7 @@ export const GlyphSection = () => { const unicode = formatCodepointAsUPlus(glyph.unicode); const sidebearings = deriveGlyphSidebearings(glyph); - const xAdvance = glyph.xAdvance; + const xAdvance = Math.round(glyph.xAdvance); const lsb = roundSidebearing(sidebearings.lsb); const rsb = roundSidebearing(sidebearings.rsb); From 962df9dcee624f22698a4f08d55086a61a5383f9 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 12 Apr 2026 17:12:39 +0100 Subject: [PATCH 32/41] Font.getPath() interpolates live when variation location is active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No cache — interpolates via Rust on each render call. When variationLocation is non-null, getPath() calls interpolateGlyph() and converts the snapshot to Path2D. All text run glyphs, hover outlines, and previews automatically show interpolated shapes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/renderer/src/lib/model/Font.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/src/lib/model/Font.ts b/apps/desktop/src/renderer/src/lib/model/Font.ts index 8b87513b..fcc48081 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.ts @@ -1,16 +1,27 @@ -import type { FontMetrics, FontMetadata, CompositeGlyph, Axis, Source, Location } from "@shift/types"; +import type { + FontMetrics, + FontMetadata, + CompositeGlyph, + Axis, + Source, + Location, +} from "@shift/types"; import type { MasterSnapshot } from "@/lib/interpolation/interpolate"; import type { InterpolationResult } from "@/bridge/NativeBridge"; import type { Bounds } from "@shift/geo"; import { signal, type WritableSignal, type Signal } from "@/lib/reactive/signal"; import type { NativeBridge } from "@/bridge"; import { getGlyphInfo } from "@/store/glyphInfo"; +import { snapshotToSvgPath } from "@/lib/interpolation/svg"; /** * Reactive font data surface. * * Auto-unwrapping getters (same pattern as Glyph). Reading `font.metrics`, * `font.unicodes`, `font.loaded` inside a computed/effect auto-tracks. + * + * When a variation location is active, getPath() returns interpolated + * paths transparently — all callers (text run, preview) get the right thing. */ export class Font { readonly #bridge: NativeBridge; @@ -69,7 +80,18 @@ export class Font { } getPath(name: string): Path2D | null { - return this.#bridge.getPath(name); + const loc = this.#$variationLocation.peek(); + if (!loc) return this.#bridge.getPath(name); + + const values: Record = {}; + for (const [k, v] of Object.entries(loc.values)) { + if (v !== undefined) values[k] = v; + } + const result = this.#bridge.interpolateGlyph(name, values); + if (!result) return this.#bridge.getPath(name); + + const svg = snapshotToSvgPath(result.instance); + return svg ? new Path2D(svg) : null; } nameForUnicode(unicode: number): string | null { From d45eadc59323bd1cbf50b7e76eef8fe11befcf78 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 12 Apr 2026 17:14:23 +0100 Subject: [PATCH 33/41] Interpolate xAdvance too: getAdvance() uses interpolated snapshot Extract #interpolate(name) helper that both getPath() and getAdvance() use. When variation location is active, advance comes from the interpolated snapshot, so text run layout spacing updates with sliders. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/renderer/src/lib/model/Font.ts | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/renderer/src/lib/model/Font.ts b/apps/desktop/src/renderer/src/lib/model/Font.ts index fcc48081..261d81c3 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.ts @@ -80,18 +80,12 @@ export class Font { } getPath(name: string): Path2D | null { - const loc = this.#$variationLocation.peek(); - if (!loc) return this.#bridge.getPath(name); - - const values: Record = {}; - for (const [k, v] of Object.entries(loc.values)) { - if (v !== undefined) values[k] = v; + const interpolated = this.#interpolate(name); + if (interpolated) { + const svg = snapshotToSvgPath(interpolated); + return svg ? new Path2D(svg) : null; } - const result = this.#bridge.interpolateGlyph(name, values); - if (!result) return this.#bridge.getPath(name); - - const svg = snapshotToSvgPath(result.instance); - return svg ? new Path2D(svg) : null; + return this.#bridge.getPath(name); } nameForUnicode(unicode: number): string | null { @@ -108,13 +102,24 @@ export class Font { } getAdvance(name: string): number | null { - return this.#bridge.getAdvance(name); + return this.#interpolate(name)?.xAdvance ?? this.#bridge.getAdvance(name); } getBbox(name: string): Bounds | null { return this.#bridge.getBbox(name); } + #interpolate(name: string): import("@shift/types").GlyphSnapshot | null { + const loc = this.#$variationLocation.peek(); + if (!loc) return null; + + const values: Record = {}; + for (const [k, v] of Object.entries(loc.values)) { + if (v !== undefined) values[k] = v; + } + return this.#bridge.interpolateGlyph(name, values)?.instance ?? null; + } + getSvgPath(name: string): string | null { return this.#bridge.getSvgPath(name); } From c89afec6d82e7420d5406db3724acc861b731ad7 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 12 Apr 2026 17:19:20 +0100 Subject: [PATCH 34/41] Fix text run re-layout on variation change TextRunController.#deriveRenderState now tracks font.$variationLocation. When the variation slider moves, the text run layout recomputes with interpolated advances, triggering a static redraw that renders interpolated glyph paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../desktop/src/renderer/src/lib/tools/text/TextRunController.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/renderer/src/lib/tools/text/TextRunController.ts b/apps/desktop/src/renderer/src/lib/tools/text/TextRunController.ts index 23a7e4b9..4cded946 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/TextRunController.ts +++ b/apps/desktop/src/renderer/src/lib/tools/text/TextRunController.ts @@ -798,6 +798,7 @@ export class TextRunController { const r = this.#signal().value; // track run state changes (creates signal if needed) const font = this.#$font.value; // track font changes if (!font) return null; + void font.$variationLocation.value; // track variation location changes const layout = r.glyphs.length > 0 From cb48f2813b7e0b1640ce75051f4947fb85ffafaa Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 12 Apr 2026 19:10:59 +0100 Subject: [PATCH 35/41] =?UTF-8?q?Memoize=20interpolation=20per=20location?= =?UTF-8?q?=20=E2=80=94=20no=20Rust=20calls=20during=20pan/zoom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interpolated snapshots are memoized per glyph name for the current variation location. When the location changes (slider move), the memo clears and glyphs are re-interpolated lazily on next render. Panning, zooming, and other redraws reuse the memoized results — zero FFI cost. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/desktop/src/renderer/src/lib/model/Font.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/src/lib/model/Font.ts b/apps/desktop/src/renderer/src/lib/model/Font.ts index 261d81c3..b4575bfc 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.ts @@ -5,6 +5,7 @@ import type { Axis, Source, Location, + GlyphSnapshot, } from "@shift/types"; import type { MasterSnapshot } from "@/lib/interpolation/interpolate"; import type { InterpolationResult } from "@/bridge/NativeBridge"; @@ -29,6 +30,8 @@ export class Font { readonly #$unicodes: WritableSignal; readonly #$metrics: WritableSignal; readonly #$variationLocation: WritableSignal; + #interpolationMemo = new Map(); + #memoLocation: Location | null = null; constructor(bridge: NativeBridge) { this.#bridge = bridge; @@ -109,15 +112,25 @@ export class Font { return this.#bridge.getBbox(name); } - #interpolate(name: string): import("@shift/types").GlyphSnapshot | null { + #interpolate(name: string): GlyphSnapshot | null { const loc = this.#$variationLocation.peek(); if (!loc) return null; + if (this.#memoLocation !== loc) { + this.#interpolationMemo.clear(); + this.#memoLocation = loc; + } + + const cached = this.#interpolationMemo.get(name); + if (cached) return cached; + const values: Record = {}; for (const [k, v] of Object.entries(loc.values)) { if (v !== undefined) values[k] = v; } - return this.#bridge.interpolateGlyph(name, values)?.instance ?? null; + const snapshot = this.#bridge.interpolateGlyph(name, values)?.instance ?? null; + if (snapshot) this.#interpolationMemo.set(name, snapshot); + return snapshot; } getSvgPath(name: string): string | null { From fc340215e25a260da62634da8c35dd1050002453 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 12 Apr 2026 20:57:17 +0100 Subject: [PATCH 36/41] =?UTF-8?q?Variation=20engine:=20weights=20from=20Ru?= =?UTF-8?q?st,=20deltas=20=C3=97=20apply=20in=20TS=20signals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Variation class owns the interpolation pipeline: - Rust computes per-master weights via computeVariationWeights() - TS holds master deltas as Float64Arrays - variation.interpolate(name) applies weights × deltas - Callers' computed signals cache results automatically Font.getPath/getAdvance/getSvgPath transparently return interpolated results when a variation location is active. Simplified VariationPanel and GlyphPreview — no manual interpolation. Delete dead Rust NAPI methods (getGlyphAdvance, getGlyphBbox by unicode). Delete dead editing_layer_for method. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/renderer/src/bridge/NativeBridge.ts | 13 ++ .../renderer/src/components/GlyphPreview.tsx | 44 +--- .../src/components/VariationPanel.tsx | 63 ++--- .../src/lib/interpolation/interpolate.test.ts | 79 +++++-- .../src/renderer/src/lib/model/Font.ts | 110 ++++----- .../src/renderer/src/lib/model/Glyph.ts | 1 + .../src/renderer/src/lib/model/Variation.ts | 221 ++++++++++++++++++ .../src/lib/tools/text/TextRunController.ts | 2 +- crates/shift-core/src/interpolation.rs | 67 +++++- crates/shift-node/index.d.ts | 11 +- crates/shift-node/src/font_engine.rs | 61 ++--- scripts/oxlint/shift-plugin.mjs | 1 + 12 files changed, 463 insertions(+), 210 deletions(-) create mode 100644 apps/desktop/src/renderer/src/lib/model/Variation.ts diff --git a/apps/desktop/src/renderer/src/bridge/NativeBridge.ts b/apps/desktop/src/renderer/src/bridge/NativeBridge.ts index aea8a57f..75a4f1ea 100644 --- a/apps/desktop/src/renderer/src/bridge/NativeBridge.ts +++ b/apps/desktop/src/renderer/src/bridge/NativeBridge.ts @@ -28,6 +28,12 @@ import { ContourContent } from "@/lib/clipboard"; import type { NodePositionUpdateList } from "@/types/positionUpdate"; import { Glyph } from "@/lib/model/Glyph"; +export interface VariationWeights { + weights: number[]; + defaultIndex: number; + masterIndices: number[]; +} + export interface InterpolationResult { instance: GlyphSnapshot; errors: Array<{ sourceIndex: number; sourceName: string; message: string }>; @@ -168,6 +174,13 @@ export class NativeBridge { return JSON.parse(json) as MasterSnapshot[]; } + /** @knipclassignore — compute per-master weights for a designspace location */ + computeVariationWeights(location: Record): VariationWeights | null { + const json = this.#raw.computeVariationWeights(JSON.stringify({ values: location })); + if (!json) return null; + return JSON.parse(json) as VariationWeights; + } + /** @knipclassignore — interpolate a glyph at a designspace location in Rust */ interpolateGlyph( glyphName: string, diff --git a/apps/desktop/src/renderer/src/components/GlyphPreview.tsx b/apps/desktop/src/renderer/src/components/GlyphPreview.tsx index 030794a7..a928848b 100644 --- a/apps/desktop/src/renderer/src/components/GlyphPreview.tsx +++ b/apps/desktop/src/renderer/src/components/GlyphPreview.tsx @@ -1,8 +1,6 @@ -import { memo, useMemo } from "react"; +import { memo } from "react"; import type { FontMetrics } from "@shift/types"; import type { Font } from "@/lib/model/Font"; -import { useSignalState } from "@/lib/reactive"; -import { snapshotToSvgPath } from "@/lib/interpolation/svg"; export const CELL_HEIGHT = 75; @@ -33,6 +31,12 @@ export function computeViewBoxHeight(metrics: FontMetrics): number { return metrics.ascender - metrics.descender + marginTop + marginBottom; } +interface GlyphPreviewProps { + unicode: number; + font: Font; + height?: number; +} + export function computeCellWidth( metrics: FontMetrics | null, advance: number | null, @@ -47,42 +51,18 @@ export function computeCellWidth( return Math.max(cellHeight, width); } -interface GlyphPreviewProps { - unicode: number; - font: Font; - height?: number; -} export const GlyphPreview = memo(function GlyphPreview({ unicode, font, height = CELL_HEIGHT, }: GlyphPreviewProps) { const name = font.nameForUnicode(unicode); - if (!name) { - return null; - } - - const variationLocation = useSignalState(font.$variationLocation); - - const interpolated = useMemo(() => { - if (!variationLocation || !font.isVariable()) return null; - - const target: Record = {}; - for (const [tag, value] of Object.entries(variationLocation.values)) { - if (value !== undefined) target[tag] = value; - } - - const result = font.interpolateGlyph(name, target); - if (!result) return null; - - return { - path: snapshotToSvgPath(result.instance), - advance: result.instance.xAdvance, - }; - }, [variationLocation, name, font]); + if (!name) return null; - const svgPath = interpolated?.path ?? font.getSvgPath(name); - const advance = interpolated?.advance ?? font.getAdvance(name); + // font.getSvgPath and font.getAdvance are now interpolation-aware + // via the Variation engine. No manual interpolation needed. + const svgPath = font.getSvgPath(name); + const advance = font.getAdvance(name); const fontMetrics = font.getMetrics(); const cellWidth = computeCellWidth(fontMetrics, advance, height); const containerStyle = { width: cellWidth, height }; diff --git a/apps/desktop/src/renderer/src/components/VariationPanel.tsx b/apps/desktop/src/renderer/src/components/VariationPanel.tsx index 3deeeece..053a74cf 100644 --- a/apps/desktop/src/renderer/src/components/VariationPanel.tsx +++ b/apps/desktop/src/renderer/src/components/VariationPanel.tsx @@ -1,9 +1,8 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import type { Axis } from "@shift/types"; import { SidebarSection } from "./sidebar-right/SidebarSection"; import { getEditor } from "@/store/store"; import { useSignalState } from "@/lib/reactive"; -import { interpolateGlyph, type MasterSnapshot } from "@/lib/interpolation/interpolate"; /** Variation axis slider panel — shown when a variable font is loaded. */ export const VariationPanel = () => { @@ -13,9 +12,6 @@ export const VariationPanel = () => { const [axes, setAxes] = useState([]); const [location, setLocation] = useState>({}); - const mastersRef = useRef(null); - const [isInterpolating, setIsInterpolating] = useState(false); - const [editingGlyph, setEditingGlyph] = useState(null); useEffect(() => { if (!fontLoaded || !font.isVariable()) { @@ -33,42 +29,29 @@ export const VariationPanel = () => { setLocation(defaults); }, [fontLoaded, font]); - useEffect(() => { - setEditingGlyph(editor.getActiveGlyphName()); - }); - - useEffect(() => { - if (axes.length === 0 || !editingGlyph) { - mastersRef.current = null; - return; - } - - mastersRef.current = font.getGlyphMasterSnapshots(editingGlyph); - setIsInterpolating(false); - }, [axes, editingGlyph, font]); - const handleAxisChange = useCallback( (tag: string, value: number) => { const newLocation = { ...location, [tag]: value }; setLocation(newLocation); - const ms = mastersRef.current; - if (!ms || ms.length < 2) return; + if (!font.variation) return; + font.variation.setLocation({ values: newLocation }); - const result = interpolateGlyph(ms, axes, newLocation); - if (!result) return; - - setIsInterpolating(true); + // Apply interpolated snapshot to the editing glyph + const glyphName = editor.getActiveGlyphName(); + if (!glyphName) return; + const snap = font.variation.interpolate(glyphName); + if (!snap) return; const glyph = editor.glyph.peek(); - if (glyph) glyph.apply(result.instance); - font.setVariationLocation({ values: newLocation }); + if (glyph) glyph.apply(snap); }, - [location, axes, editor, font], + [location, editor, font], ); const handleMasterClick = useCallback( (sourceName: string) => { - const masters = mastersRef.current; + if (!font.variation) return; + const masters = font.getGlyphMasterSnapshots(editor.getActiveGlyphName() ?? ""); if (!masters) return; const master = masters.find((m) => m.sourceName === sourceName); @@ -80,34 +63,30 @@ export const VariationPanel = () => { } setLocation(newLocation); - setIsInterpolating(true); + font.variation.setLocation({ values: newLocation }); const glyph = editor.glyph.peek(); if (glyph) glyph.apply(master.snapshot); - font.setVariationLocation({ values: newLocation }); }, [axes, editor, font], ); - const handleResetToSession = useCallback(() => { - if (!isInterpolating) return; + const handleReset = useCallback(() => { + if (!font.variation) return; + font.variation.setLocation(null); - setIsInterpolating(false); - font.setVariationLocation(null); const glyph = editor.glyph.peek(); - if (glyph) { - glyph.restoreSnapshot(glyph.toSnapshot()); - } + if (glyph) glyph.restoreSnapshot(glyph.toSnapshot()); const defaults: Record = {}; for (const axis of axes) { defaults[axis.tag] = axis.default; } setLocation(defaults); - }, [isInterpolating, editor, font, axes]); + }, [editor, font, axes]); if (axes.length === 0) return null; - const masters = mastersRef.current ?? []; + const masters = font.getGlyphMasterSnapshots(editor.getActiveGlyphName() ?? "") ?? []; return ( @@ -134,11 +113,11 @@ export const VariationPanel = () => { ))}
)} - {isInterpolating && ( + {font.variation?.location && ( diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts index d5187443..4d326eb3 100644 --- a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts @@ -88,8 +88,24 @@ describe("normalizeAxisValue", () => { describe("checkCompatibility", () => { it("returns null for compatible masters", () => { - const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 0, y: 0 }, { x: 100, y: 0 }]), 500); - const bold = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 10, y: 0 }, { x: 110, y: 0 }]), 600); + const light = makeMaster( + "Light", + { wght: 0 }, + makeContour([ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + ]), + 500, + ); + const bold = makeMaster( + "Bold", + { wght: 1000 }, + makeContour([ + { x: 10, y: 0 }, + { x: 110, y: 0 }, + ]), + 600, + ); expect(checkCompatibility([light, bold])).toBeNull(); }); @@ -104,7 +120,15 @@ describe("checkCompatibility", () => { it("reports point count mismatch", () => { const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 0, y: 0 }]), 500); - const bold = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 0, y: 0 }, { x: 100, y: 0 }]), 600); + const bold = makeMaster( + "Bold", + { wght: 1000 }, + makeContour([ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + ]), + 600, + ); expect(checkCompatibility([light, bold])).toContain("points"); }); }); @@ -165,10 +189,30 @@ describe("interpolateGlyph", () => { const wdthAxis = makeAxis({ tag: "wdth", name: "Width" }); const wghtAxis = makeAxis({ tag: "wght", name: "Weight" }); - const lc = makeMaster("LightCondensed", { wdth: 0, wght: 0 }, makeContour([{ x: 0, y: 0 }]), 400); - const bc = makeMaster("BoldCondensed", { wdth: 0, wght: 1000 }, makeContour([{ x: 100, y: 0 }]), 500); - const lw = makeMaster("LightWide", { wdth: 1000, wght: 0 }, makeContour([{ x: 0, y: 100 }]), 600); - const bw = makeMaster("BoldWide", { wdth: 1000, wght: 1000 }, makeContour([{ x: 100, y: 100 }]), 700); + const lc = makeMaster( + "LightCondensed", + { wdth: 0, wght: 0 }, + makeContour([{ x: 0, y: 0 }]), + 400, + ); + const bc = makeMaster( + "BoldCondensed", + { wdth: 0, wght: 1000 }, + makeContour([{ x: 100, y: 0 }]), + 500, + ); + const lw = makeMaster( + "LightWide", + { wdth: 1000, wght: 0 }, + makeContour([{ x: 0, y: 100 }]), + 600, + ); + const bw = makeMaster( + "BoldWide", + { wdth: 1000, wght: 1000 }, + makeContour([{ x: 100, y: 100 }]), + 700, + ); const { instance, errors } = unwrap( interpolateGlyph([lc, bc, lw, bw], [wdthAxis, wghtAxis], { wdth: 500, wght: 500 }), @@ -225,10 +269,7 @@ describe("interpolateGlyph — incompatible sources", () => { sourceId: "Bad", sourceName: "Bad", location: { values: { wght: 1000 } }, - snapshot: makeSnapshot( - [makeContour([{ x: 0, y: 0 }]), makeContour([{ x: 50, y: 50 }])], - 700, - ), + snapshot: makeSnapshot([makeContour([{ x: 0, y: 0 }]), makeContour([{ x: 50, y: 50 }])], 700), }; const { instance, errors } = unwrap( @@ -246,17 +287,18 @@ describe("interpolateGlyph — incompatible sources", () => { sourceId: "Bad1", sourceName: "Bad1", location: { values: { wght: 500 } }, - snapshot: makeSnapshot( - [makeContour([{ x: 0, y: 0 }]), makeContour([{ x: 50, y: 50 }])], - 600, - ), + snapshot: makeSnapshot([makeContour([{ x: 0, y: 0 }]), makeContour([{ x: 50, y: 50 }])], 600), }; const bad2: MasterSnapshot = { sourceId: "Bad2", sourceName: "Bad2", location: { values: { wght: 1000 } }, snapshot: makeSnapshot( - [makeContour([{ x: 0, y: 0 }]), makeContour([{ x: 50, y: 50 }]), makeContour([{ x: 99, y: 99 }])], + [ + makeContour([{ x: 0, y: 0 }]), + makeContour([{ x: 50, y: 50 }]), + makeContour([{ x: 99, y: 99 }]), + ], 700, ), }; @@ -277,10 +319,7 @@ describe("interpolateGlyph — incompatible sources", () => { sourceId: "Wonky", sourceName: "Wonky", location: { values: { wght: 1000 } }, - snapshot: makeSnapshot( - [makeContour([{ x: 0, y: 0 }]), makeContour([{ x: 50, y: 50 }])], - 700, - ), + snapshot: makeSnapshot([makeContour([{ x: 0, y: 0 }]), makeContour([{ x: 50, y: 50 }])], 700), }; const { errors } = unwrap(interpolateGlyph([defaultMaster, incompat], axes, { wght: 500 })); diff --git a/apps/desktop/src/renderer/src/lib/model/Font.ts b/apps/desktop/src/renderer/src/lib/model/Font.ts index b4575bfc..e19cb828 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.ts @@ -1,44 +1,32 @@ -import type { - FontMetrics, - FontMetadata, - CompositeGlyph, - Axis, - Source, - Location, - GlyphSnapshot, -} from "@shift/types"; +import type { FontMetrics, FontMetadata, CompositeGlyph, Axis, Source } from "@shift/types"; import type { MasterSnapshot } from "@/lib/interpolation/interpolate"; import type { InterpolationResult } from "@/bridge/NativeBridge"; import type { Bounds } from "@shift/geo"; import { signal, type WritableSignal, type Signal } from "@/lib/reactive/signal"; import type { NativeBridge } from "@/bridge"; import { getGlyphInfo } from "@/store/glyphInfo"; +import { Variation } from "./Variation"; import { snapshotToSvgPath } from "@/lib/interpolation/svg"; /** * Reactive font data surface. * - * Auto-unwrapping getters (same pattern as Glyph). Reading `font.metrics`, - * `font.unicodes`, `font.loaded` inside a computed/effect auto-tracks. - * - * When a variation location is active, getPath() returns interpolated - * paths transparently — all callers (text run, preview) get the right thing. + * When a variable font is loaded, `font.variation` provides the + * interpolation engine. `getPath()` and `getAdvance()` transparently + * return interpolated results when a variation location is active. */ export class Font { readonly #bridge: NativeBridge; readonly #$loaded: WritableSignal; readonly #$unicodes: WritableSignal; readonly #$metrics: WritableSignal; - readonly #$variationLocation: WritableSignal; - #interpolationMemo = new Map(); - #memoLocation: Location | null = null; + variation: Variation | null = null; constructor(bridge: NativeBridge) { this.#bridge = bridge; this.#$loaded = signal(false); this.#$unicodes = signal([]); this.#$metrics = signal(null); - this.#$variationLocation = signal(null); } /** @knipclassignore */ @@ -56,19 +44,18 @@ export class Font { return this.#$metrics.value; } - /** Raw signals for React hooks that need Signal. */ /** @knipclassignore */ - get $loaded() { + get $loaded(): Signal { return this.#$loaded as Signal; } /** @knipclassignore */ - get $unicodes() { + get $unicodes(): Signal { return this.#$unicodes as Signal; } /** @knipclassignore */ - get $metrics() { + get $metrics(): Signal { return this.#$metrics as Signal; } @@ -77,25 +64,26 @@ export class Font { return this.#bridge.getMetadata(); } - /** Sync metrics fetch (non-null, call only when font is loaded). */ getMetrics(): FontMetrics { return this.#bridge.getMetrics(); } getPath(name: string): Path2D | null { - const interpolated = this.#interpolate(name); - if (interpolated) { - const svg = snapshotToSvgPath(interpolated); - return svg ? new Path2D(svg) : null; - } - return this.#bridge.getPath(name); + if (!this.variation) return this.#bridge.getPath(name); + + const snap = this.variation.interpolate(name); + if (!snap) return this.#bridge.getPath(name); + + const svg = snapshotToSvgPath(snap); + if (!svg) return this.#bridge.getPath(name); + + return new Path2D(svg); } nameForUnicode(unicode: number): string | null { return this.#bridge.nameForUnicode(unicode); } - /** Resolve unicode to glyph name. Checks font first, then glyph-info DB, then fallback. */ glyphName(unicode: number): string { return ( this.#bridge.nameForUnicode(unicode) ?? @@ -105,69 +93,48 @@ export class Font { } getAdvance(name: string): number | null { - return this.#interpolate(name)?.xAdvance ?? this.#bridge.getAdvance(name); + if (!this.variation) return this.#bridge.getAdvance(name); + + const snap = this.variation.interpolate(name); + if (!snap) return this.#bridge.getAdvance(name); + + return snap.xAdvance; } getBbox(name: string): Bounds | null { return this.#bridge.getBbox(name); } - #interpolate(name: string): GlyphSnapshot | null { - const loc = this.#$variationLocation.peek(); - if (!loc) return null; - - if (this.#memoLocation !== loc) { - this.#interpolationMemo.clear(); - this.#memoLocation = loc; - } - - const cached = this.#interpolationMemo.get(name); - if (cached) return cached; - - const values: Record = {}; - for (const [k, v] of Object.entries(loc.values)) { - if (v !== undefined) values[k] = v; - } - const snapshot = this.#bridge.interpolateGlyph(name, values)?.instance ?? null; - if (snapshot) this.#interpolationMemo.set(name, snapshot); - return snapshot; - } - getSvgPath(name: string): string | null { - return this.#bridge.getSvgPath(name); - } + if (!this.variation) return this.#bridge.getSvgPath(name); - /** @knipclassignore — used by GlyphPreview for variation interpolation */ - get $variationLocation(): Signal { - return this.#$variationLocation; - } + const snap = this.variation.interpolate(name); + if (!snap) return this.#bridge.getSvgPath(name); - /** @knipclassignore — used by VariationPanel */ - setVariationLocation(location: Location | null): void { - this.#$variationLocation.set(location); + return snapshotToSvgPath(snap) || this.#bridge.getSvgPath(name); } - /** @knipclassignore — used by VariationPanel component */ + /** @knipclassignore */ isVariable(): boolean { return this.#bridge.isVariable(); } - /** @knipclassignore — used by VariationPanel component */ + /** @knipclassignore */ getAxes(): Axis[] { return this.#bridge.getAxes(); } - /** @knipclassignore — used by VariationPanel component */ + /** @knipclassignore */ getSources(): Source[] { return this.#bridge.getSources(); } - /** @knipclassignore — used by VariationPanel component */ + /** @knipclassignore */ getGlyphMasterSnapshots(glyphName: string): MasterSnapshot[] | null { return this.#bridge.getGlyphMasterSnapshots(glyphName); } - /** @knipclassignore — interpolate a glyph at a designspace location in Rust */ + /** @knipclassignore */ interpolateGlyph( glyphName: string, location: Record, @@ -186,16 +153,25 @@ export class Font { this.#$unicodes.set(unicodes); this.#$metrics.set(metrics); this.#$loaded.set(true); + + if (this.#bridge.isVariable()) { + this.variation = new Variation(this.#bridge); + const glyphNames = unicodes.map((u) => this.glyphName(u)); + this.variation.loadMasters(glyphNames); + } else { + this.variation = null; + } } async save(path: string): Promise { return this.#bridge.saveFontAsync(path); } - /** @knipclassignore — called when closing a document */ + /** @knipclassignore */ reset(): void { this.#$loaded.set(false); this.#$unicodes.set([]); this.#$metrics.set(null); + this.variation = null; } } diff --git a/apps/desktop/src/renderer/src/lib/model/Glyph.ts b/apps/desktop/src/renderer/src/lib/model/Glyph.ts index f5693c3b..16be7c66 100644 --- a/apps/desktop/src/renderer/src/lib/model/Glyph.ts +++ b/apps/desktop/src/renderer/src/lib/model/Glyph.ts @@ -59,6 +59,7 @@ export class Contour { this.#closed = signal(snapshot.closed); this.#points = signal(snapshot.points); this.#path = computed(() => buildPath2D(this.#points.value, this.#closed.value)); + this.#bounds = computed(() => { const pts = this.#points.value; const isClosed = this.#closed.value; diff --git a/apps/desktop/src/renderer/src/lib/model/Variation.ts b/apps/desktop/src/renderer/src/lib/model/Variation.ts new file mode 100644 index 00000000..e5fee38c --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/model/Variation.ts @@ -0,0 +1,221 @@ +/** + * Variation engine — weights from Rust, deltas × apply in TS signals. + * + * Rust computes per-master scalar weights (once per location change). + * TS holds master deltas as flat Float64Arrays and applies weights in + * computed signals. The computeds cache results — pan/zoom/hover cost + * zero. Only slider moves trigger recomputation. + */ + +import type { + GlyphSnapshot, + ContourSnapshot, + PointSnapshot, + AnchorSnapshot, + Location, +} from "@shift/types"; +import type { NativeBridge } from "@/bridge"; +import type { MasterSnapshot } from "@/lib/interpolation/interpolate"; +import { + signal, + computed, + type WritableSignal, + type ComputedSignal, + type Signal, +} from "@/lib/reactive/signal"; + +interface MasterDeltas { + deltas: Float64Array[]; + valueLength: number; +} + +export class Variation { + readonly #bridge: NativeBridge; + readonly #$location: WritableSignal; + readonly #$weights: ComputedSignal; + readonly #deltas = new Map(); + readonly #defaults = new Map(); + + constructor(bridge: NativeBridge) { + this.#bridge = bridge; + this.#$location = signal(null); + this.#$weights = computed(() => { + const loc = this.#$location.value; + if (!loc) return null; + const result = this.#bridge.computeVariationWeights(loc.values as Record); + if (!result) return null; + return result.weights; + }); + } + + /** @knipclassignore */ + setLocation(location: Location | null): void { + this.#$location.set(location); + } + + /** @knipclassignore */ + get location(): Location | null { + return this.#$location.value; + } + + get $location(): Signal { + return this.#$location; + } + + loadMasters(glyphNames: string[]): void { + for (const name of glyphNames) { + if (this.#deltas.has(name)) continue; + const masters = this.#bridge.getGlyphMasterSnapshots(name); + if (!masters || masters.length < 2) continue; + + const defaultIdx = findDefaultMaster(masters); + this.#defaults.set(name, masters[defaultIdx].snapshot); + this.#deltas.set(name, computeDeltas(masters, defaultIdx)); + } + } + + /** @knipclassignore */ + updateEditingDeltas(glyphName: string, currentSnapshot: GlyphSnapshot): void { + const existing = this.#deltas.get(glyphName); + if (!existing) return; + + const defaultVec = flattenSnapshot(currentSnapshot); + const oldDefault = this.#defaults.get(glyphName); + if (!oldDefault) return; + + this.#defaults.set(glyphName, currentSnapshot); + + // Recompute deltas relative to new default + // delta[i] = master[i] - newDefault + // We have: old delta[i] = master[i] - oldDefault + // So: new delta[i] = old delta[i] + (oldDefault - newDefault) + const oldDefaultVec = flattenSnapshot(oldDefault); + const diff = new Float64Array(defaultVec.length); + for (let j = 0; j < diff.length; j++) { + diff[j] = oldDefaultVec[j] - defaultVec[j]; + } + + for (let i = 0; i < existing.deltas.length; i++) { + const d = existing.deltas[i]; + const updated = new Float64Array(d.length); + for (let j = 0; j < d.length; j++) { + updated[j] = d[j] + diff[j]; + } + existing.deltas[i] = updated; + } + } + + interpolate(name: string): GlyphSnapshot | null { + const weights = this.#$weights.value; + if (!weights) return null; + + const deltas = this.#deltas.get(name); + if (!deltas) return null; + + const def = this.#defaults.get(name); + if (!def) return null; + + return applyWeightsToDeltas(def, deltas, weights); + } +} + +function findDefaultMaster(masters: MasterSnapshot[]): number { + for (let i = 0; i < masters.length; i++) { + const vals = masters[i].location.values; + const isDefault = + !vals || + Object.keys(vals).length === 0 || + Object.values(vals).every((v) => v === undefined || v === 0); + if (isDefault) return i; + } + return 0; +} + +function computeDeltas(masters: MasterSnapshot[], defaultIdx: number): MasterDeltas { + const defaultVec = flattenSnapshot(masters[defaultIdx].snapshot); + const deltas: Float64Array[] = masters.map((m) => { + const vec = flattenSnapshot(m.snapshot); + const delta = new Float64Array(vec.length); + for (let i = 0; i < vec.length; i++) { + delta[i] = vec[i] - defaultVec[i]; + } + return delta; + }); + + return { deltas, valueLength: defaultVec.length }; +} + +function flattenSnapshot(snap: GlyphSnapshot): Float64Array { + let pointCount = 0; + for (const c of snap.contours) { + pointCount += c.points.length; + } + const anchorCount = snap.anchors.length; + const len = 1 + pointCount * 2 + anchorCount * 2; + const arr = new Float64Array(len); + + let idx = 0; + arr[idx++] = snap.xAdvance; + + for (const contour of snap.contours) { + for (const point of contour.points) { + arr[idx++] = point.x; + arr[idx++] = point.y; + } + } + + for (const anchor of snap.anchors) { + arr[idx++] = anchor.x; + arr[idx++] = anchor.y; + } + + return arr; +} + +function applyWeightsToDeltas( + defaultSnap: GlyphSnapshot, + deltas: MasterDeltas, + weights: number[], +): GlyphSnapshot { + const defaultVec = flattenSnapshot(defaultSnap); + const result = new Float64Array(defaultVec.length); + result.set(defaultVec); + + for (let i = 0; i < weights.length && i < deltas.deltas.length; i++) { + const w = weights[i]; + if (Math.abs(w) < 1e-10) continue; + const d = deltas.deltas[i]; + for (let j = 0; j < result.length; j++) { + result[j] += w * d[j]; + } + } + + return unflattenSnapshot(result, defaultSnap); +} + +function unflattenSnapshot(values: Float64Array, template: GlyphSnapshot): GlyphSnapshot { + let idx = 0; + const xAdvance = values[idx++]; + + const contours: ContourSnapshot[] = template.contours.map((tc) => { + const points: PointSnapshot[] = tc.points.map((tp) => ({ + ...tp, + x: values[idx++], + y: values[idx++], + })); + return { ...tc, points }; + }); + + const anchors: AnchorSnapshot[] = template.anchors.map((ta) => ({ + ...ta, + x: values[idx++], + y: values[idx++], + })); + + return { + ...template, + xAdvance, + contours, + anchors, + }; +} diff --git a/apps/desktop/src/renderer/src/lib/tools/text/TextRunController.ts b/apps/desktop/src/renderer/src/lib/tools/text/TextRunController.ts index 4cded946..b97713e7 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/TextRunController.ts +++ b/apps/desktop/src/renderer/src/lib/tools/text/TextRunController.ts @@ -798,7 +798,7 @@ export class TextRunController { const r = this.#signal().value; // track run state changes (creates signal if needed) const font = this.#$font.value; // track font changes if (!font) return null; - void font.$variationLocation.value; // track variation location changes + if (font.variation) void font.variation.$location.value; // track variation location changes const layout = r.glyphs.length > 0 diff --git a/crates/shift-core/src/interpolation.rs b/crates/shift-core/src/interpolation.rs index 9ee5e536..b2293ca7 100644 --- a/crates/shift-core/src/interpolation.rs +++ b/crates/shift-core/src/interpolation.rs @@ -240,8 +240,6 @@ fn build_variation_model( } } -// --- Snapshot flattening --- - fn flatten_snapshot(snap: &GlyphSnapshot) -> Vec { let mut values = vec![snap.x_advance]; for contour in &snap.contours { @@ -340,6 +338,71 @@ fn location_to_key(loc: &SparseLocation) -> String { .join(",") } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VariationWeights { + pub weights: Vec, + pub default_index: usize, + pub master_indices: Vec, +} + +pub fn compute_variation_weights( + masters: &[MasterSnapshot], + axes: &[Axis], + target: &Location, +) -> Option { + if masters.len() < 2 { + return None; + } + + let axis_order: Vec = axes.iter().map(|a| a.tag().to_string()).collect(); + + let normalized_masters: Vec<(usize, SparseLocation)> = masters + .iter() + .enumerate() + .map(|(i, m)| (i, normalize_location(&m.location, axes))) + .collect(); + + let has_default = normalized_masters.iter().any(|(_, loc)| loc.is_empty()); + if !has_default { + return None; + } + + let mut seen = HashSet::new(); + let deduped: Vec<(usize, SparseLocation)> = normalized_masters + .into_iter() + .filter(|(_, loc)| seen.insert(location_to_key(loc))) + .collect(); + + if deduped.len() < 2 { + return None; + } + + let default_idx = deduped.iter().position(|(_, loc)| loc.is_empty()).unwrap(); + + let model_locations: Vec = deduped.iter().map(|(_, loc)| loc.clone()).collect(); + let model = build_variation_model(&model_locations, &axis_order); + + let target_normalized = normalize_location(target, axes); + + let mut weights = vec![0.0f64; model.supports.len()]; + for (sorted_idx, support) in model.supports.iter().enumerate() { + weights[sorted_idx] = support_scalar(&target_normalized, support); + } + + let master_indices: Vec = model.mapping.iter().map(|&i| deduped[i].0).collect(); + + Some(VariationWeights { + weights, + default_index: model + .mapping + .iter() + .position(|&i| i == default_idx) + .unwrap_or(0), + master_indices, + }) +} + pub fn interpolate_glyph( masters: &[MasterSnapshot], axes: &[Axis], diff --git a/crates/shift-node/index.d.ts b/crates/shift-node/index.d.ts index ed6f7a1a..94b0b40b 100644 --- a/crates/shift-node/index.d.ts +++ b/crates/shift-node/index.d.ts @@ -16,13 +16,7 @@ export declare class FontEngine { */ getGlyphSvgPath(unicode: number): string | null getGlyphSvgPathByName(glyphName: string): string | null - getGlyphAdvance(unicode: number): number | null getGlyphAdvanceByName(glyphName: string): number | null - /** - * Returns a tight bounding box `[min_x, min_y, max_x, max_y]` for the glyph, - * including resolved component contours. - */ - getGlyphBbox(unicode: number): Array | null getGlyphBboxByName(glyphName: string): Array | null getGlyphCompositeComponents(glyphName: string): string | null isVariable(): boolean @@ -30,6 +24,11 @@ export declare class FontEngine { getSources(): string /** Returns a JSON array of master snapshots for a glyph. */ getGlyphMasterSnapshots(glyphName: string): string | null + /** + * Compute variation weights for a designspace location. + * Returns per-master scalar weights — glyph-independent, computed once per location. + */ + computeVariationWeights(locationJson: string): string | null /** Interpolate a glyph at a given designspace location. */ interpolateGlyph(glyphName: string, locationJson: string): string | null startEditSession(glyphRef: JsGlyphRef): void diff --git a/crates/shift-node/src/font_engine.rs b/crates/shift-node/src/font_engine.rs index f18c5f22..9c6cb4da 100644 --- a/crates/shift-node/src/font_engine.rs +++ b/crates/shift-node/src/font_engine.rs @@ -11,8 +11,8 @@ use shift_core::{ edit_session::EditSession, font_loader::FontLoader, snapshot::{CommandResult, GlyphSnapshot, MasterSnapshot, RenderContourSnapshot}, - AnchorId, BooleanOp, ContourId, Font, FontWriter, Glyph, GlyphLayer, GuidelineId, LayerId, Location, - NodePositionUpdate, NodeRef, PasteContour, PointId, PointType, UfoWriter, + AnchorId, BooleanOp, ContourId, Font, FontWriter, Glyph, GlyphLayer, GuidelineId, LayerId, + Location, NodePositionUpdate, NodeRef, PasteContour, PointId, PointType, UfoWriter, }; use std::collections::HashSet; @@ -387,16 +387,6 @@ impl FontEngine { sorted } - fn editing_layer_for(&self, unicode: u32) -> Option<&GlyphLayer> { - if let Some(session) = &self.current_edit_session { - if session.unicode() == unicode { - return Some(session.layer()); - } - } - let glyph = self.font.glyph_by_unicode(unicode)?; - self.default_layer_for_glyph(glyph) - } - #[napi] /// Returns SVG path data for the glyph, including resolved component /// contours from composite dependencies. @@ -438,38 +428,12 @@ impl FontEngine { Some(path) } - #[napi] - pub fn get_glyph_advance(&self, unicode: u32) -> Option { - let layer = self.editing_layer_for(unicode)?; - Some(layer.width()) - } - #[napi] pub fn get_glyph_advance_by_name(&self, glyph_name: String) -> Option { let (_, layer) = self.editing_target_for_name(&glyph_name)?; Some(layer.width()) } - #[napi] - /// Returns a tight bounding box `[min_x, min_y, max_x, max_y]` for the glyph, - /// including resolved component contours. - pub fn get_glyph_bbox(&self, unicode: u32) -> Option> { - let (glyph_name, layer) = self.editing_target_for_unicode(unicode)?; - let component_contours = self.flatten_component_contours_for_layer(layer, glyph_name); - let bbox = layer_bbox(layer, &component_contours); - if bbox.is_none() { - composite_debug!( - "get_glyph_bbox U+{:04X} '{}': empty bbox (contours={}, components={}, flattened_contours={})", - unicode, - glyph_name, - layer.contours().len(), - layer.components().len(), - component_contours.len() - ); - } - bbox.map(|(min_x, min_y, max_x, max_y)| vec![min_x, min_y, max_x, max_y]) - } - #[napi] pub fn get_glyph_bbox_by_name(&self, glyph_name: String) -> Option> { let (resolved_name, layer) = self.editing_target_for_name(&glyph_name)?; @@ -543,6 +507,23 @@ impl FontEngine { Some(to_json(&masters)) } + /// Compute variation weights for a designspace location. + /// Returns per-master scalar weights — glyph-independent, computed once per location. + #[napi] + pub fn compute_variation_weights(&self, location_json: String) -> Option { + if !self.font.is_variable() { + return None; + } + // We need master snapshots to build the model, but only the locations matter for weights. + // Use any glyph's masters — the model is glyph-independent. + let any_glyph_name = self.font.glyphs().keys().next()?; + let masters = self.build_master_snapshots(any_glyph_name)?; + let target: Location = serde_json::from_str(&location_json).expect("Invalid location JSON"); + let axes = self.font.axes(); + let result = shift_core::interpolation::compute_variation_weights(&masters, axes, &target)?; + Some(to_json(&result)) + } + /// Interpolate a glyph at a given designspace location. #[napi] pub fn interpolate_glyph(&self, glyph_name: String, location_json: String) -> Option { @@ -1229,10 +1210,10 @@ mod tests { let path_str = ufo_path.to_str().unwrap(); let mut engine = FontEngine::new(); engine.load_font(path_str.to_string()).unwrap(); - let bbox = engine.get_glyph_bbox(65); + let bbox = engine.get_glyph_bbox_by_name("A".to_string()); assert!( bbox.is_some(), - "get_glyph_bbox(65) should return Some for MutatorSans A" + "get_glyph_bbox_by_name('A') should return Some for MutatorSans A" ); let b = bbox.unwrap(); assert_eq!(b.len(), 4); diff --git a/scripts/oxlint/shift-plugin.mjs b/scripts/oxlint/shift-plugin.mjs index a78307c8..1c601414 100644 --- a/scripts/oxlint/shift-plugin.mjs +++ b/scripts/oxlint/shift-plugin.mjs @@ -579,6 +579,7 @@ export default { "Font.ts", // Font wraps bridge "Glyph.ts", // Glyph wraps bridge "glyph.ts", // Glyph wraps bridge (case insensitive match) + "Variation.ts", // Variation engine uses bridge for weights "testing/", // test helpers create bridge instances "store/", // app store creates bridge "commands/", // commands access bridge via context From 38041190d08b18c1737d41ea8c1af5a4a1c82d2c Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 12 Apr 2026 21:07:06 +0100 Subject: [PATCH 37/41] Fix interpolation: use Rust-computed forward-differenced deltas The TS-side delta computation (simple master - default) was wrong for multi-axis fonts. The VariationModel requires forward differencing where each delta subtracts contributions from previous masters. Added compute_glyph_deltas() to Rust which returns properly decomposed deltas aligned with the weight indices. The Variation engine now stores these Rust-computed deltas instead of computing them in TS. Deleted the incorrect TS computeDeltas() and flattenSnapshot() functions. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/renderer/src/bridge/NativeBridge.ts | 12 +++ .../src/renderer/src/lib/model/Variation.ts | 96 ++++--------------- crates/shift-core/src/interpolation.rs | 72 ++++++++++++++ crates/shift-node/index.d.ts | 2 + crates/shift-node/src/font_engine.rs | 9 ++ 5 files changed, 115 insertions(+), 76 deletions(-) diff --git a/apps/desktop/src/renderer/src/bridge/NativeBridge.ts b/apps/desktop/src/renderer/src/bridge/NativeBridge.ts index 75a4f1ea..84588471 100644 --- a/apps/desktop/src/renderer/src/bridge/NativeBridge.ts +++ b/apps/desktop/src/renderer/src/bridge/NativeBridge.ts @@ -34,6 +34,11 @@ export interface VariationWeights { masterIndices: number[]; } +export interface GlyphDeltas { + defaultValues: number[]; + deltas: number[][]; +} + export interface InterpolationResult { instance: GlyphSnapshot; errors: Array<{ sourceIndex: number; sourceName: string; message: string }>; @@ -181,6 +186,13 @@ export class NativeBridge { return JSON.parse(json) as VariationWeights; } + /** @knipclassignore — compute decomposed deltas for a glyph */ + computeGlyphDeltas(glyphName: string): GlyphDeltas | null { + const json = this.#raw.computeGlyphDeltas(glyphName); + if (!json) return null; + return JSON.parse(json) as GlyphDeltas; + } + /** @knipclassignore — interpolate a glyph at a designspace location in Rust */ interpolateGlyph( glyphName: string, diff --git a/apps/desktop/src/renderer/src/lib/model/Variation.ts b/apps/desktop/src/renderer/src/lib/model/Variation.ts index e5fee38c..07a969b3 100644 --- a/apps/desktop/src/renderer/src/lib/model/Variation.ts +++ b/apps/desktop/src/renderer/src/lib/model/Variation.ts @@ -25,8 +25,8 @@ import { } from "@/lib/reactive/signal"; interface MasterDeltas { + defaultValues: Float64Array; deltas: Float64Array[]; - valueLength: number; } export class Variation { @@ -65,44 +65,30 @@ export class Variation { loadMasters(glyphNames: string[]): void { for (const name of glyphNames) { if (this.#deltas.has(name)) continue; + + // Get properly decomposed deltas from Rust (forward differencing) + const glyphDeltas = this.#bridge.computeGlyphDeltas(name); + if (!glyphDeltas) continue; + + // Also need a default snapshot for unflatten template const masters = this.#bridge.getGlyphMasterSnapshots(name); if (!masters || masters.length < 2) continue; - const defaultIdx = findDefaultMaster(masters); + this.#defaults.set(name, masters[defaultIdx].snapshot); - this.#deltas.set(name, computeDeltas(masters, defaultIdx)); + this.#deltas.set(name, { + defaultValues: new Float64Array(glyphDeltas.defaultValues), + deltas: glyphDeltas.deltas.map((d) => new Float64Array(d)), + }); } } - /** @knipclassignore */ - updateEditingDeltas(glyphName: string, currentSnapshot: GlyphSnapshot): void { - const existing = this.#deltas.get(glyphName); - if (!existing) return; - - const defaultVec = flattenSnapshot(currentSnapshot); - const oldDefault = this.#defaults.get(glyphName); - if (!oldDefault) return; - - this.#defaults.set(glyphName, currentSnapshot); - - // Recompute deltas relative to new default - // delta[i] = master[i] - newDefault - // We have: old delta[i] = master[i] - oldDefault - // So: new delta[i] = old delta[i] + (oldDefault - newDefault) - const oldDefaultVec = flattenSnapshot(oldDefault); - const diff = new Float64Array(defaultVec.length); - for (let j = 0; j < diff.length; j++) { - diff[j] = oldDefaultVec[j] - defaultVec[j]; - } - - for (let i = 0; i < existing.deltas.length; i++) { - const d = existing.deltas[i]; - const updated = new Float64Array(d.length); - for (let j = 0; j < d.length; j++) { - updated[j] = d[j] + diff[j]; - } - existing.deltas[i] = updated; - } + /** @knipclassignore — re-fetch deltas from Rust after editing a glyph */ + updateEditingDeltas(glyphName: string, _currentSnapshot: GlyphSnapshot): void { + // Re-fetch from Rust which has the updated master data + this.#deltas.delete(glyphName); + this.#defaults.delete(glyphName); + this.loadMasters([glyphName]); } interpolate(name: string): GlyphSnapshot | null { @@ -131,55 +117,13 @@ function findDefaultMaster(masters: MasterSnapshot[]): number { return 0; } -function computeDeltas(masters: MasterSnapshot[], defaultIdx: number): MasterDeltas { - const defaultVec = flattenSnapshot(masters[defaultIdx].snapshot); - const deltas: Float64Array[] = masters.map((m) => { - const vec = flattenSnapshot(m.snapshot); - const delta = new Float64Array(vec.length); - for (let i = 0; i < vec.length; i++) { - delta[i] = vec[i] - defaultVec[i]; - } - return delta; - }); - - return { deltas, valueLength: defaultVec.length }; -} - -function flattenSnapshot(snap: GlyphSnapshot): Float64Array { - let pointCount = 0; - for (const c of snap.contours) { - pointCount += c.points.length; - } - const anchorCount = snap.anchors.length; - const len = 1 + pointCount * 2 + anchorCount * 2; - const arr = new Float64Array(len); - - let idx = 0; - arr[idx++] = snap.xAdvance; - - for (const contour of snap.contours) { - for (const point of contour.points) { - arr[idx++] = point.x; - arr[idx++] = point.y; - } - } - - for (const anchor of snap.anchors) { - arr[idx++] = anchor.x; - arr[idx++] = anchor.y; - } - - return arr; -} - function applyWeightsToDeltas( defaultSnap: GlyphSnapshot, deltas: MasterDeltas, weights: number[], ): GlyphSnapshot { - const defaultVec = flattenSnapshot(defaultSnap); - const result = new Float64Array(defaultVec.length); - result.set(defaultVec); + const result = new Float64Array(deltas.defaultValues.length); + result.set(deltas.defaultValues); for (let i = 0; i < weights.length && i < deltas.deltas.length; i++) { const w = weights[i]; diff --git a/crates/shift-core/src/interpolation.rs b/crates/shift-core/src/interpolation.rs index b2293ca7..c8fc758f 100644 --- a/crates/shift-core/src/interpolation.rs +++ b/crates/shift-core/src/interpolation.rs @@ -346,6 +346,78 @@ pub struct VariationWeights { pub master_indices: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GlyphDeltas { + pub default_values: Vec, + pub deltas: Vec>, +} + +pub fn compute_glyph_deltas(masters: &[MasterSnapshot], axes: &[Axis]) -> Option { + if masters.len() < 2 { + return None; + } + + let axis_order: Vec = axes.iter().map(|a| a.tag().to_string()).collect(); + + let normalized_masters: Vec<(usize, SparseLocation)> = masters + .iter() + .enumerate() + .map(|(i, m)| (i, normalize_location(&m.location, axes))) + .collect(); + + let has_default = normalized_masters.iter().any(|(_, loc)| loc.is_empty()); + if !has_default { + return None; + } + + let mut seen = HashSet::new(); + let deduped: Vec<(usize, SparseLocation)> = normalized_masters + .into_iter() + .filter(|(_, loc)| seen.insert(location_to_key(loc))) + .collect(); + + if deduped.len() < 2 { + return None; + } + + let default_idx = deduped.iter().position(|(_, loc)| loc.is_empty()).unwrap(); + let default_master = &masters[deduped[default_idx].0]; + + let model_locations: Vec = deduped.iter().map(|(_, loc)| loc.clone()).collect(); + let model = build_variation_model(&model_locations, &axis_order); + + let default_values = flatten_snapshot(&default_master.snapshot); + let value_len = default_values.len(); + + let mut deltas: Vec> = Vec::with_capacity(model.mapping.len()); + + for (sorted_idx, &orig_model_idx) in model.mapping.iter().enumerate() { + let master_idx = deduped[orig_model_idx].0; + let master = &masters[master_idx]; + + match check_compatibility(&default_master.snapshot, &master.snapshot) { + Ok(()) => { + let master_values = flatten_snapshot(&master.snapshot); + let mut delta = sub_values(&master_values, &zero_values(value_len)); + for &(prev_sorted, weight) in &model.delta_weights[sorted_idx] { + let contribution = mul_scalar_values(&deltas[prev_sorted], weight); + delta = sub_values(&delta, &contribution); + } + deltas.push(delta); + } + Err(_) => { + deltas.push(zero_values(value_len)); + } + } + } + + Some(GlyphDeltas { + default_values, + deltas, + }) +} + pub fn compute_variation_weights( masters: &[MasterSnapshot], axes: &[Axis], diff --git a/crates/shift-node/index.d.ts b/crates/shift-node/index.d.ts index 94b0b40b..c63f2905 100644 --- a/crates/shift-node/index.d.ts +++ b/crates/shift-node/index.d.ts @@ -29,6 +29,8 @@ export declare class FontEngine { * Returns per-master scalar weights — glyph-independent, computed once per location. */ computeVariationWeights(locationJson: string): string | null + /** Compute properly decomposed deltas for a glyph using the VariationModel. */ + computeGlyphDeltas(glyphName: string): string | null /** Interpolate a glyph at a given designspace location. */ interpolateGlyph(glyphName: string, locationJson: string): string | null startEditSession(glyphRef: JsGlyphRef): void diff --git a/crates/shift-node/src/font_engine.rs b/crates/shift-node/src/font_engine.rs index 9c6cb4da..c4655f32 100644 --- a/crates/shift-node/src/font_engine.rs +++ b/crates/shift-node/src/font_engine.rs @@ -524,6 +524,15 @@ impl FontEngine { Some(to_json(&result)) } + /// Compute properly decomposed deltas for a glyph using the VariationModel. + #[napi] + pub fn compute_glyph_deltas(&self, glyph_name: String) -> Option { + let masters = self.build_master_snapshots(&glyph_name)?; + let axes = self.font.axes(); + let result = shift_core::interpolation::compute_glyph_deltas(&masters, axes)?; + Some(to_json(&result)) + } + /// Interpolate a glyph at a given designspace location. #[napi] pub fn interpolate_glyph(&self, glyph_name: String, location_json: String) -> Option { From b29b6e6b72e76b3f39aecf34cbe0b00884215311 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 12 Apr 2026 21:10:20 +0100 Subject: [PATCH 38/41] Simplify Variation: use Rust interpolateGlyph directly with per-location cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The weights/deltas split was producing wrong results due to inconsistent model ordering between separate Rust calls. Revert to using the proven interpolateGlyph() from Rust which handles the full VariationModel correctly. Cache results per glyph per location. Cache clears on location change. Pan/zoom reads from cache — zero cost. The weights × deltas optimization can be revisited later with a single Rust function that returns both weights and deltas in consistent order. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/renderer/src/lib/model/Font.ts | 8 +- .../src/renderer/src/lib/model/Variation.ts | 150 +++--------------- 2 files changed, 22 insertions(+), 136 deletions(-) diff --git a/apps/desktop/src/renderer/src/lib/model/Font.ts b/apps/desktop/src/renderer/src/lib/model/Font.ts index e19cb828..223b3120 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.ts @@ -154,13 +154,7 @@ export class Font { this.#$metrics.set(metrics); this.#$loaded.set(true); - if (this.#bridge.isVariable()) { - this.variation = new Variation(this.#bridge); - const glyphNames = unicodes.map((u) => this.glyphName(u)); - this.variation.loadMasters(glyphNames); - } else { - this.variation = null; - } + this.variation = this.#bridge.isVariable() ? new Variation(this.#bridge) : null; } async save(path: string): Promise { diff --git a/apps/desktop/src/renderer/src/lib/model/Variation.ts b/apps/desktop/src/renderer/src/lib/model/Variation.ts index 07a969b3..fe8b26d8 100644 --- a/apps/desktop/src/renderer/src/lib/model/Variation.ts +++ b/apps/desktop/src/renderer/src/lib/model/Variation.ts @@ -1,55 +1,28 @@ /** - * Variation engine — weights from Rust, deltas × apply in TS signals. + * Variation engine — caches interpolation results per location. * - * Rust computes per-master scalar weights (once per location change). - * TS holds master deltas as flat Float64Arrays and applies weights in - * computed signals. The computeds cache results — pan/zoom/hover cost - * zero. Only slider moves trigger recomputation. + * Calls Rust's interpolateGlyph() once per glyph per location change. + * Results cached in a Map, cleared on location change. + * Pan/zoom/hover reads from cache — zero Rust calls. */ -import type { - GlyphSnapshot, - ContourSnapshot, - PointSnapshot, - AnchorSnapshot, - Location, -} from "@shift/types"; +import type { GlyphSnapshot, Location } from "@shift/types"; import type { NativeBridge } from "@/bridge"; -import type { MasterSnapshot } from "@/lib/interpolation/interpolate"; -import { - signal, - computed, - type WritableSignal, - type ComputedSignal, - type Signal, -} from "@/lib/reactive/signal"; - -interface MasterDeltas { - defaultValues: Float64Array; - deltas: Float64Array[]; -} +import { signal, type WritableSignal, type Signal } from "@/lib/reactive/signal"; export class Variation { readonly #bridge: NativeBridge; readonly #$location: WritableSignal; - readonly #$weights: ComputedSignal; - readonly #deltas = new Map(); - readonly #defaults = new Map(); + #cache = new Map(); constructor(bridge: NativeBridge) { this.#bridge = bridge; this.#$location = signal(null); - this.#$weights = computed(() => { - const loc = this.#$location.value; - if (!loc) return null; - const result = this.#bridge.computeVariationWeights(loc.values as Record); - if (!result) return null; - return result.weights; - }); } /** @knipclassignore */ setLocation(location: Location | null): void { + this.#cache.clear(); this.#$location.set(location); } @@ -62,104 +35,23 @@ export class Variation { return this.#$location; } - loadMasters(glyphNames: string[]): void { - for (const name of glyphNames) { - if (this.#deltas.has(name)) continue; - - // Get properly decomposed deltas from Rust (forward differencing) - const glyphDeltas = this.#bridge.computeGlyphDeltas(name); - if (!glyphDeltas) continue; - - // Also need a default snapshot for unflatten template - const masters = this.#bridge.getGlyphMasterSnapshots(name); - if (!masters || masters.length < 2) continue; - const defaultIdx = findDefaultMaster(masters); - - this.#defaults.set(name, masters[defaultIdx].snapshot); - this.#deltas.set(name, { - defaultValues: new Float64Array(glyphDeltas.defaultValues), - deltas: glyphDeltas.deltas.map((d) => new Float64Array(d)), - }); - } - } - - /** @knipclassignore — re-fetch deltas from Rust after editing a glyph */ - updateEditingDeltas(glyphName: string, _currentSnapshot: GlyphSnapshot): void { - // Re-fetch from Rust which has the updated master data - this.#deltas.delete(glyphName); - this.#defaults.delete(glyphName); - this.loadMasters([glyphName]); - } - interpolate(name: string): GlyphSnapshot | null { - const weights = this.#$weights.value; - if (!weights) return null; - - const deltas = this.#deltas.get(name); - if (!deltas) return null; - - const def = this.#defaults.get(name); - if (!def) return null; + // Read the signal to create reactive dependency + const loc = this.#$location.value; + if (!loc) return null; - return applyWeightsToDeltas(def, deltas, weights); - } -} - -function findDefaultMaster(masters: MasterSnapshot[]): number { - for (let i = 0; i < masters.length; i++) { - const vals = masters[i].location.values; - const isDefault = - !vals || - Object.keys(vals).length === 0 || - Object.values(vals).every((v) => v === undefined || v === 0); - if (isDefault) return i; - } - return 0; -} - -function applyWeightsToDeltas( - defaultSnap: GlyphSnapshot, - deltas: MasterDeltas, - weights: number[], -): GlyphSnapshot { - const result = new Float64Array(deltas.defaultValues.length); - result.set(deltas.defaultValues); + const cached = this.#cache.get(name); + if (cached) return cached; - for (let i = 0; i < weights.length && i < deltas.deltas.length; i++) { - const w = weights[i]; - if (Math.abs(w) < 1e-10) continue; - const d = deltas.deltas[i]; - for (let j = 0; j < result.length; j++) { - result[j] += w * d[j]; + const values: Record = {}; + for (const [k, v] of Object.entries(loc.values)) { + if (v !== undefined) values[k] = v; } - } - - return unflattenSnapshot(result, defaultSnap); -} -function unflattenSnapshot(values: Float64Array, template: GlyphSnapshot): GlyphSnapshot { - let idx = 0; - const xAdvance = values[idx++]; + const result = this.#bridge.interpolateGlyph(name, values); + if (!result) return null; - const contours: ContourSnapshot[] = template.contours.map((tc) => { - const points: PointSnapshot[] = tc.points.map((tp) => ({ - ...tp, - x: values[idx++], - y: values[idx++], - })); - return { ...tc, points }; - }); - - const anchors: AnchorSnapshot[] = template.anchors.map((ta) => ({ - ...ta, - x: values[idx++], - y: values[idx++], - })); - - return { - ...template, - xAdvance, - contours, - anchors, - }; + this.#cache.set(name, result.instance); + return result.instance; + } } From 07bff467586ee5d8084a3eae81d60d941cae63ae Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 12 Apr 2026 21:26:13 +0100 Subject: [PATCH 39/41] =?UTF-8?q?Revert=20Variation=20engine=20=E2=80=94?= =?UTF-8?q?=20Rust=20interpolation=20has=20multi-axis=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Variation engine design (weights × deltas) is correct but the Rust interpolateGlyph produces wrong results for multi-axis fonts. The TS interpolation (fonttools port) works correctly. Reverted to pre-Variation state. The Rust interpolation needs debugging before we can use it for the Variation engine. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/renderer/src/bridge/NativeBridge.ts | 25 ---- .../renderer/src/components/GlyphPreview.tsx | 44 ++++-- .../src/components/VariationPanel.tsx | 63 +++++--- .../src/renderer/src/lib/model/Font.ts | 73 +++++---- .../src/renderer/src/lib/model/Glyph.ts | 1 - .../src/renderer/src/lib/model/Variation.ts | 57 ------- .../src/lib/tools/text/TextRunController.ts | 1 - crates/shift-core/src/interpolation.rs | 139 +----------------- crates/shift-node/index.d.ts | 13 +- crates/shift-node/src/font_engine.rs | 66 +++++---- scripts/oxlint/shift-plugin.mjs | 1 - 11 files changed, 155 insertions(+), 328 deletions(-) delete mode 100644 apps/desktop/src/renderer/src/lib/model/Variation.ts diff --git a/apps/desktop/src/renderer/src/bridge/NativeBridge.ts b/apps/desktop/src/renderer/src/bridge/NativeBridge.ts index 84588471..aea8a57f 100644 --- a/apps/desktop/src/renderer/src/bridge/NativeBridge.ts +++ b/apps/desktop/src/renderer/src/bridge/NativeBridge.ts @@ -28,17 +28,6 @@ import { ContourContent } from "@/lib/clipboard"; import type { NodePositionUpdateList } from "@/types/positionUpdate"; import { Glyph } from "@/lib/model/Glyph"; -export interface VariationWeights { - weights: number[]; - defaultIndex: number; - masterIndices: number[]; -} - -export interface GlyphDeltas { - defaultValues: number[]; - deltas: number[][]; -} - export interface InterpolationResult { instance: GlyphSnapshot; errors: Array<{ sourceIndex: number; sourceName: string; message: string }>; @@ -179,20 +168,6 @@ export class NativeBridge { return JSON.parse(json) as MasterSnapshot[]; } - /** @knipclassignore — compute per-master weights for a designspace location */ - computeVariationWeights(location: Record): VariationWeights | null { - const json = this.#raw.computeVariationWeights(JSON.stringify({ values: location })); - if (!json) return null; - return JSON.parse(json) as VariationWeights; - } - - /** @knipclassignore — compute decomposed deltas for a glyph */ - computeGlyphDeltas(glyphName: string): GlyphDeltas | null { - const json = this.#raw.computeGlyphDeltas(glyphName); - if (!json) return null; - return JSON.parse(json) as GlyphDeltas; - } - /** @knipclassignore — interpolate a glyph at a designspace location in Rust */ interpolateGlyph( glyphName: string, diff --git a/apps/desktop/src/renderer/src/components/GlyphPreview.tsx b/apps/desktop/src/renderer/src/components/GlyphPreview.tsx index a928848b..030794a7 100644 --- a/apps/desktop/src/renderer/src/components/GlyphPreview.tsx +++ b/apps/desktop/src/renderer/src/components/GlyphPreview.tsx @@ -1,6 +1,8 @@ -import { memo } from "react"; +import { memo, useMemo } from "react"; import type { FontMetrics } from "@shift/types"; import type { Font } from "@/lib/model/Font"; +import { useSignalState } from "@/lib/reactive"; +import { snapshotToSvgPath } from "@/lib/interpolation/svg"; export const CELL_HEIGHT = 75; @@ -31,12 +33,6 @@ export function computeViewBoxHeight(metrics: FontMetrics): number { return metrics.ascender - metrics.descender + marginTop + marginBottom; } -interface GlyphPreviewProps { - unicode: number; - font: Font; - height?: number; -} - export function computeCellWidth( metrics: FontMetrics | null, advance: number | null, @@ -51,18 +47,42 @@ export function computeCellWidth( return Math.max(cellHeight, width); } +interface GlyphPreviewProps { + unicode: number; + font: Font; + height?: number; +} export const GlyphPreview = memo(function GlyphPreview({ unicode, font, height = CELL_HEIGHT, }: GlyphPreviewProps) { const name = font.nameForUnicode(unicode); - if (!name) return null; + if (!name) { + return null; + } + + const variationLocation = useSignalState(font.$variationLocation); + + const interpolated = useMemo(() => { + if (!variationLocation || !font.isVariable()) return null; + + const target: Record = {}; + for (const [tag, value] of Object.entries(variationLocation.values)) { + if (value !== undefined) target[tag] = value; + } + + const result = font.interpolateGlyph(name, target); + if (!result) return null; + + return { + path: snapshotToSvgPath(result.instance), + advance: result.instance.xAdvance, + }; + }, [variationLocation, name, font]); - // font.getSvgPath and font.getAdvance are now interpolation-aware - // via the Variation engine. No manual interpolation needed. - const svgPath = font.getSvgPath(name); - const advance = font.getAdvance(name); + const svgPath = interpolated?.path ?? font.getSvgPath(name); + const advance = interpolated?.advance ?? font.getAdvance(name); const fontMetrics = font.getMetrics(); const cellWidth = computeCellWidth(fontMetrics, advance, height); const containerStyle = { width: cellWidth, height }; diff --git a/apps/desktop/src/renderer/src/components/VariationPanel.tsx b/apps/desktop/src/renderer/src/components/VariationPanel.tsx index 053a74cf..3deeeece 100644 --- a/apps/desktop/src/renderer/src/components/VariationPanel.tsx +++ b/apps/desktop/src/renderer/src/components/VariationPanel.tsx @@ -1,8 +1,9 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { Axis } from "@shift/types"; import { SidebarSection } from "./sidebar-right/SidebarSection"; import { getEditor } from "@/store/store"; import { useSignalState } from "@/lib/reactive"; +import { interpolateGlyph, type MasterSnapshot } from "@/lib/interpolation/interpolate"; /** Variation axis slider panel — shown when a variable font is loaded. */ export const VariationPanel = () => { @@ -12,6 +13,9 @@ export const VariationPanel = () => { const [axes, setAxes] = useState([]); const [location, setLocation] = useState>({}); + const mastersRef = useRef(null); + const [isInterpolating, setIsInterpolating] = useState(false); + const [editingGlyph, setEditingGlyph] = useState(null); useEffect(() => { if (!fontLoaded || !font.isVariable()) { @@ -29,29 +33,42 @@ export const VariationPanel = () => { setLocation(defaults); }, [fontLoaded, font]); + useEffect(() => { + setEditingGlyph(editor.getActiveGlyphName()); + }); + + useEffect(() => { + if (axes.length === 0 || !editingGlyph) { + mastersRef.current = null; + return; + } + + mastersRef.current = font.getGlyphMasterSnapshots(editingGlyph); + setIsInterpolating(false); + }, [axes, editingGlyph, font]); + const handleAxisChange = useCallback( (tag: string, value: number) => { const newLocation = { ...location, [tag]: value }; setLocation(newLocation); - if (!font.variation) return; - font.variation.setLocation({ values: newLocation }); + const ms = mastersRef.current; + if (!ms || ms.length < 2) return; - // Apply interpolated snapshot to the editing glyph - const glyphName = editor.getActiveGlyphName(); - if (!glyphName) return; - const snap = font.variation.interpolate(glyphName); - if (!snap) return; + const result = interpolateGlyph(ms, axes, newLocation); + if (!result) return; + + setIsInterpolating(true); const glyph = editor.glyph.peek(); - if (glyph) glyph.apply(snap); + if (glyph) glyph.apply(result.instance); + font.setVariationLocation({ values: newLocation }); }, - [location, editor, font], + [location, axes, editor, font], ); const handleMasterClick = useCallback( (sourceName: string) => { - if (!font.variation) return; - const masters = font.getGlyphMasterSnapshots(editor.getActiveGlyphName() ?? ""); + const masters = mastersRef.current; if (!masters) return; const master = masters.find((m) => m.sourceName === sourceName); @@ -63,30 +80,34 @@ export const VariationPanel = () => { } setLocation(newLocation); - font.variation.setLocation({ values: newLocation }); + setIsInterpolating(true); const glyph = editor.glyph.peek(); if (glyph) glyph.apply(master.snapshot); + font.setVariationLocation({ values: newLocation }); }, [axes, editor, font], ); - const handleReset = useCallback(() => { - if (!font.variation) return; - font.variation.setLocation(null); + const handleResetToSession = useCallback(() => { + if (!isInterpolating) return; + setIsInterpolating(false); + font.setVariationLocation(null); const glyph = editor.glyph.peek(); - if (glyph) glyph.restoreSnapshot(glyph.toSnapshot()); + if (glyph) { + glyph.restoreSnapshot(glyph.toSnapshot()); + } const defaults: Record = {}; for (const axis of axes) { defaults[axis.tag] = axis.default; } setLocation(defaults); - }, [editor, font, axes]); + }, [isInterpolating, editor, font, axes]); if (axes.length === 0) return null; - const masters = font.getGlyphMasterSnapshots(editor.getActiveGlyphName() ?? "") ?? []; + const masters = mastersRef.current ?? []; return ( @@ -113,11 +134,11 @@ export const VariationPanel = () => { ))} )} - {font.variation?.location && ( + {isInterpolating && ( diff --git a/apps/desktop/src/renderer/src/lib/model/Font.ts b/apps/desktop/src/renderer/src/lib/model/Font.ts index 223b3120..29f76344 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.ts @@ -1,32 +1,37 @@ -import type { FontMetrics, FontMetadata, CompositeGlyph, Axis, Source } from "@shift/types"; +import type { + FontMetrics, + FontMetadata, + CompositeGlyph, + Axis, + Source, + Location, +} from "@shift/types"; import type { MasterSnapshot } from "@/lib/interpolation/interpolate"; import type { InterpolationResult } from "@/bridge/NativeBridge"; import type { Bounds } from "@shift/geo"; import { signal, type WritableSignal, type Signal } from "@/lib/reactive/signal"; import type { NativeBridge } from "@/bridge"; import { getGlyphInfo } from "@/store/glyphInfo"; -import { Variation } from "./Variation"; -import { snapshotToSvgPath } from "@/lib/interpolation/svg"; /** * Reactive font data surface. * - * When a variable font is loaded, `font.variation` provides the - * interpolation engine. `getPath()` and `getAdvance()` transparently - * return interpolated results when a variation location is active. + * Auto-unwrapping getters (same pattern as Glyph). Reading `font.metrics`, + * `font.unicodes`, `font.loaded` inside a computed/effect auto-tracks. */ export class Font { readonly #bridge: NativeBridge; readonly #$loaded: WritableSignal; readonly #$unicodes: WritableSignal; readonly #$metrics: WritableSignal; - variation: Variation | null = null; + readonly #$variationLocation: WritableSignal; constructor(bridge: NativeBridge) { this.#bridge = bridge; this.#$loaded = signal(false); this.#$unicodes = signal([]); this.#$metrics = signal(null); + this.#$variationLocation = signal(null); } /** @knipclassignore */ @@ -44,18 +49,19 @@ export class Font { return this.#$metrics.value; } + /** Raw signals for React hooks that need Signal. */ /** @knipclassignore */ - get $loaded(): Signal { + get $loaded() { return this.#$loaded as Signal; } /** @knipclassignore */ - get $unicodes(): Signal { + get $unicodes() { return this.#$unicodes as Signal; } /** @knipclassignore */ - get $metrics(): Signal { + get $metrics() { return this.#$metrics as Signal; } @@ -64,26 +70,20 @@ export class Font { return this.#bridge.getMetadata(); } + /** Sync metrics fetch (non-null, call only when font is loaded). */ getMetrics(): FontMetrics { return this.#bridge.getMetrics(); } getPath(name: string): Path2D | null { - if (!this.variation) return this.#bridge.getPath(name); - - const snap = this.variation.interpolate(name); - if (!snap) return this.#bridge.getPath(name); - - const svg = snapshotToSvgPath(snap); - if (!svg) return this.#bridge.getPath(name); - - return new Path2D(svg); + return this.#bridge.getPath(name); } nameForUnicode(unicode: number): string | null { return this.#bridge.nameForUnicode(unicode); } + /** Resolve unicode to glyph name. Checks font first, then glyph-info DB, then fallback. */ glyphName(unicode: number): string { return ( this.#bridge.nameForUnicode(unicode) ?? @@ -93,12 +93,7 @@ export class Font { } getAdvance(name: string): number | null { - if (!this.variation) return this.#bridge.getAdvance(name); - - const snap = this.variation.interpolate(name); - if (!snap) return this.#bridge.getAdvance(name); - - return snap.xAdvance; + return this.#bridge.getAdvance(name); } getBbox(name: string): Bounds | null { @@ -106,35 +101,40 @@ export class Font { } getSvgPath(name: string): string | null { - if (!this.variation) return this.#bridge.getSvgPath(name); + return this.#bridge.getSvgPath(name); + } - const snap = this.variation.interpolate(name); - if (!snap) return this.#bridge.getSvgPath(name); + /** @knipclassignore — used by GlyphPreview for variation interpolation */ + get $variationLocation(): Signal { + return this.#$variationLocation; + } - return snapshotToSvgPath(snap) || this.#bridge.getSvgPath(name); + /** @knipclassignore — used by VariationPanel */ + setVariationLocation(location: Location | null): void { + this.#$variationLocation.set(location); } - /** @knipclassignore */ + /** @knipclassignore — used by VariationPanel component */ isVariable(): boolean { return this.#bridge.isVariable(); } - /** @knipclassignore */ + /** @knipclassignore — used by VariationPanel component */ getAxes(): Axis[] { return this.#bridge.getAxes(); } - /** @knipclassignore */ + /** @knipclassignore — used by VariationPanel component */ getSources(): Source[] { return this.#bridge.getSources(); } - /** @knipclassignore */ + /** @knipclassignore — used by VariationPanel component */ getGlyphMasterSnapshots(glyphName: string): MasterSnapshot[] | null { return this.#bridge.getGlyphMasterSnapshots(glyphName); } - /** @knipclassignore */ + /** @knipclassignore — interpolate a glyph at a designspace location in Rust */ interpolateGlyph( glyphName: string, location: Record, @@ -153,19 +153,16 @@ export class Font { this.#$unicodes.set(unicodes); this.#$metrics.set(metrics); this.#$loaded.set(true); - - this.variation = this.#bridge.isVariable() ? new Variation(this.#bridge) : null; } async save(path: string): Promise { return this.#bridge.saveFontAsync(path); } - /** @knipclassignore */ + /** @knipclassignore — called when closing a document */ reset(): void { this.#$loaded.set(false); this.#$unicodes.set([]); this.#$metrics.set(null); - this.variation = null; } } diff --git a/apps/desktop/src/renderer/src/lib/model/Glyph.ts b/apps/desktop/src/renderer/src/lib/model/Glyph.ts index 16be7c66..f5693c3b 100644 --- a/apps/desktop/src/renderer/src/lib/model/Glyph.ts +++ b/apps/desktop/src/renderer/src/lib/model/Glyph.ts @@ -59,7 +59,6 @@ export class Contour { this.#closed = signal(snapshot.closed); this.#points = signal(snapshot.points); this.#path = computed(() => buildPath2D(this.#points.value, this.#closed.value)); - this.#bounds = computed(() => { const pts = this.#points.value; const isClosed = this.#closed.value; diff --git a/apps/desktop/src/renderer/src/lib/model/Variation.ts b/apps/desktop/src/renderer/src/lib/model/Variation.ts deleted file mode 100644 index fe8b26d8..00000000 --- a/apps/desktop/src/renderer/src/lib/model/Variation.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Variation engine — caches interpolation results per location. - * - * Calls Rust's interpolateGlyph() once per glyph per location change. - * Results cached in a Map, cleared on location change. - * Pan/zoom/hover reads from cache — zero Rust calls. - */ - -import type { GlyphSnapshot, Location } from "@shift/types"; -import type { NativeBridge } from "@/bridge"; -import { signal, type WritableSignal, type Signal } from "@/lib/reactive/signal"; - -export class Variation { - readonly #bridge: NativeBridge; - readonly #$location: WritableSignal; - #cache = new Map(); - - constructor(bridge: NativeBridge) { - this.#bridge = bridge; - this.#$location = signal(null); - } - - /** @knipclassignore */ - setLocation(location: Location | null): void { - this.#cache.clear(); - this.#$location.set(location); - } - - /** @knipclassignore */ - get location(): Location | null { - return this.#$location.value; - } - - get $location(): Signal { - return this.#$location; - } - - interpolate(name: string): GlyphSnapshot | null { - // Read the signal to create reactive dependency - const loc = this.#$location.value; - if (!loc) return null; - - const cached = this.#cache.get(name); - if (cached) return cached; - - const values: Record = {}; - for (const [k, v] of Object.entries(loc.values)) { - if (v !== undefined) values[k] = v; - } - - const result = this.#bridge.interpolateGlyph(name, values); - if (!result) return null; - - this.#cache.set(name, result.instance); - return result.instance; - } -} diff --git a/apps/desktop/src/renderer/src/lib/tools/text/TextRunController.ts b/apps/desktop/src/renderer/src/lib/tools/text/TextRunController.ts index b97713e7..23a7e4b9 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/TextRunController.ts +++ b/apps/desktop/src/renderer/src/lib/tools/text/TextRunController.ts @@ -798,7 +798,6 @@ export class TextRunController { const r = this.#signal().value; // track run state changes (creates signal if needed) const font = this.#$font.value; // track font changes if (!font) return null; - if (font.variation) void font.variation.$location.value; // track variation location changes const layout = r.glyphs.length > 0 diff --git a/crates/shift-core/src/interpolation.rs b/crates/shift-core/src/interpolation.rs index c8fc758f..9ee5e536 100644 --- a/crates/shift-core/src/interpolation.rs +++ b/crates/shift-core/src/interpolation.rs @@ -240,6 +240,8 @@ fn build_variation_model( } } +// --- Snapshot flattening --- + fn flatten_snapshot(snap: &GlyphSnapshot) -> Vec { let mut values = vec![snap.x_advance]; for contour in &snap.contours { @@ -338,143 +340,6 @@ fn location_to_key(loc: &SparseLocation) -> String { .join(",") } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct VariationWeights { - pub weights: Vec, - pub default_index: usize, - pub master_indices: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GlyphDeltas { - pub default_values: Vec, - pub deltas: Vec>, -} - -pub fn compute_glyph_deltas(masters: &[MasterSnapshot], axes: &[Axis]) -> Option { - if masters.len() < 2 { - return None; - } - - let axis_order: Vec = axes.iter().map(|a| a.tag().to_string()).collect(); - - let normalized_masters: Vec<(usize, SparseLocation)> = masters - .iter() - .enumerate() - .map(|(i, m)| (i, normalize_location(&m.location, axes))) - .collect(); - - let has_default = normalized_masters.iter().any(|(_, loc)| loc.is_empty()); - if !has_default { - return None; - } - - let mut seen = HashSet::new(); - let deduped: Vec<(usize, SparseLocation)> = normalized_masters - .into_iter() - .filter(|(_, loc)| seen.insert(location_to_key(loc))) - .collect(); - - if deduped.len() < 2 { - return None; - } - - let default_idx = deduped.iter().position(|(_, loc)| loc.is_empty()).unwrap(); - let default_master = &masters[deduped[default_idx].0]; - - let model_locations: Vec = deduped.iter().map(|(_, loc)| loc.clone()).collect(); - let model = build_variation_model(&model_locations, &axis_order); - - let default_values = flatten_snapshot(&default_master.snapshot); - let value_len = default_values.len(); - - let mut deltas: Vec> = Vec::with_capacity(model.mapping.len()); - - for (sorted_idx, &orig_model_idx) in model.mapping.iter().enumerate() { - let master_idx = deduped[orig_model_idx].0; - let master = &masters[master_idx]; - - match check_compatibility(&default_master.snapshot, &master.snapshot) { - Ok(()) => { - let master_values = flatten_snapshot(&master.snapshot); - let mut delta = sub_values(&master_values, &zero_values(value_len)); - for &(prev_sorted, weight) in &model.delta_weights[sorted_idx] { - let contribution = mul_scalar_values(&deltas[prev_sorted], weight); - delta = sub_values(&delta, &contribution); - } - deltas.push(delta); - } - Err(_) => { - deltas.push(zero_values(value_len)); - } - } - } - - Some(GlyphDeltas { - default_values, - deltas, - }) -} - -pub fn compute_variation_weights( - masters: &[MasterSnapshot], - axes: &[Axis], - target: &Location, -) -> Option { - if masters.len() < 2 { - return None; - } - - let axis_order: Vec = axes.iter().map(|a| a.tag().to_string()).collect(); - - let normalized_masters: Vec<(usize, SparseLocation)> = masters - .iter() - .enumerate() - .map(|(i, m)| (i, normalize_location(&m.location, axes))) - .collect(); - - let has_default = normalized_masters.iter().any(|(_, loc)| loc.is_empty()); - if !has_default { - return None; - } - - let mut seen = HashSet::new(); - let deduped: Vec<(usize, SparseLocation)> = normalized_masters - .into_iter() - .filter(|(_, loc)| seen.insert(location_to_key(loc))) - .collect(); - - if deduped.len() < 2 { - return None; - } - - let default_idx = deduped.iter().position(|(_, loc)| loc.is_empty()).unwrap(); - - let model_locations: Vec = deduped.iter().map(|(_, loc)| loc.clone()).collect(); - let model = build_variation_model(&model_locations, &axis_order); - - let target_normalized = normalize_location(target, axes); - - let mut weights = vec![0.0f64; model.supports.len()]; - for (sorted_idx, support) in model.supports.iter().enumerate() { - weights[sorted_idx] = support_scalar(&target_normalized, support); - } - - let master_indices: Vec = model.mapping.iter().map(|&i| deduped[i].0).collect(); - - Some(VariationWeights { - weights, - default_index: model - .mapping - .iter() - .position(|&i| i == default_idx) - .unwrap_or(0), - master_indices, - }) -} - pub fn interpolate_glyph( masters: &[MasterSnapshot], axes: &[Axis], diff --git a/crates/shift-node/index.d.ts b/crates/shift-node/index.d.ts index c63f2905..ed6f7a1a 100644 --- a/crates/shift-node/index.d.ts +++ b/crates/shift-node/index.d.ts @@ -16,7 +16,13 @@ export declare class FontEngine { */ getGlyphSvgPath(unicode: number): string | null getGlyphSvgPathByName(glyphName: string): string | null + getGlyphAdvance(unicode: number): number | null getGlyphAdvanceByName(glyphName: string): number | null + /** + * Returns a tight bounding box `[min_x, min_y, max_x, max_y]` for the glyph, + * including resolved component contours. + */ + getGlyphBbox(unicode: number): Array | null getGlyphBboxByName(glyphName: string): Array | null getGlyphCompositeComponents(glyphName: string): string | null isVariable(): boolean @@ -24,13 +30,6 @@ export declare class FontEngine { getSources(): string /** Returns a JSON array of master snapshots for a glyph. */ getGlyphMasterSnapshots(glyphName: string): string | null - /** - * Compute variation weights for a designspace location. - * Returns per-master scalar weights — glyph-independent, computed once per location. - */ - computeVariationWeights(locationJson: string): string | null - /** Compute properly decomposed deltas for a glyph using the VariationModel. */ - computeGlyphDeltas(glyphName: string): string | null /** Interpolate a glyph at a given designspace location. */ interpolateGlyph(glyphName: string, locationJson: string): string | null startEditSession(glyphRef: JsGlyphRef): void diff --git a/crates/shift-node/src/font_engine.rs b/crates/shift-node/src/font_engine.rs index c4655f32..8acd2d47 100644 --- a/crates/shift-node/src/font_engine.rs +++ b/crates/shift-node/src/font_engine.rs @@ -387,6 +387,16 @@ impl FontEngine { sorted } + fn editing_layer_for(&self, unicode: u32) -> Option<&GlyphLayer> { + if let Some(session) = &self.current_edit_session { + if session.unicode() == unicode { + return Some(session.layer()); + } + } + let glyph = self.font.glyph_by_unicode(unicode)?; + self.default_layer_for_glyph(glyph) + } + #[napi] /// Returns SVG path data for the glyph, including resolved component /// contours from composite dependencies. @@ -428,12 +438,38 @@ impl FontEngine { Some(path) } + #[napi] + pub fn get_glyph_advance(&self, unicode: u32) -> Option { + let layer = self.editing_layer_for(unicode)?; + Some(layer.width()) + } + #[napi] pub fn get_glyph_advance_by_name(&self, glyph_name: String) -> Option { let (_, layer) = self.editing_target_for_name(&glyph_name)?; Some(layer.width()) } + #[napi] + /// Returns a tight bounding box `[min_x, min_y, max_x, max_y]` for the glyph, + /// including resolved component contours. + pub fn get_glyph_bbox(&self, unicode: u32) -> Option> { + let (glyph_name, layer) = self.editing_target_for_unicode(unicode)?; + let component_contours = self.flatten_component_contours_for_layer(layer, glyph_name); + let bbox = layer_bbox(layer, &component_contours); + if bbox.is_none() { + composite_debug!( + "get_glyph_bbox U+{:04X} '{}': empty bbox (contours={}, components={}, flattened_contours={})", + unicode, + glyph_name, + layer.contours().len(), + layer.components().len(), + component_contours.len() + ); + } + bbox.map(|(min_x, min_y, max_x, max_y)| vec![min_x, min_y, max_x, max_y]) + } + #[napi] pub fn get_glyph_bbox_by_name(&self, glyph_name: String) -> Option> { let (resolved_name, layer) = self.editing_target_for_name(&glyph_name)?; @@ -507,32 +543,6 @@ impl FontEngine { Some(to_json(&masters)) } - /// Compute variation weights for a designspace location. - /// Returns per-master scalar weights — glyph-independent, computed once per location. - #[napi] - pub fn compute_variation_weights(&self, location_json: String) -> Option { - if !self.font.is_variable() { - return None; - } - // We need master snapshots to build the model, but only the locations matter for weights. - // Use any glyph's masters — the model is glyph-independent. - let any_glyph_name = self.font.glyphs().keys().next()?; - let masters = self.build_master_snapshots(any_glyph_name)?; - let target: Location = serde_json::from_str(&location_json).expect("Invalid location JSON"); - let axes = self.font.axes(); - let result = shift_core::interpolation::compute_variation_weights(&masters, axes, &target)?; - Some(to_json(&result)) - } - - /// Compute properly decomposed deltas for a glyph using the VariationModel. - #[napi] - pub fn compute_glyph_deltas(&self, glyph_name: String) -> Option { - let masters = self.build_master_snapshots(&glyph_name)?; - let axes = self.font.axes(); - let result = shift_core::interpolation::compute_glyph_deltas(&masters, axes)?; - Some(to_json(&result)) - } - /// Interpolate a glyph at a given designspace location. #[napi] pub fn interpolate_glyph(&self, glyph_name: String, location_json: String) -> Option { @@ -1219,10 +1229,10 @@ mod tests { let path_str = ufo_path.to_str().unwrap(); let mut engine = FontEngine::new(); engine.load_font(path_str.to_string()).unwrap(); - let bbox = engine.get_glyph_bbox_by_name("A".to_string()); + let bbox = engine.get_glyph_bbox(65); assert!( bbox.is_some(), - "get_glyph_bbox_by_name('A') should return Some for MutatorSans A" + "get_glyph_bbox(65) should return Some for MutatorSans A" ); let b = bbox.unwrap(); assert_eq!(b.len(), 4); diff --git a/scripts/oxlint/shift-plugin.mjs b/scripts/oxlint/shift-plugin.mjs index 1c601414..a78307c8 100644 --- a/scripts/oxlint/shift-plugin.mjs +++ b/scripts/oxlint/shift-plugin.mjs @@ -579,7 +579,6 @@ export default { "Font.ts", // Font wraps bridge "Glyph.ts", // Glyph wraps bridge "glyph.ts", // Glyph wraps bridge (case insensitive match) - "Variation.ts", // Variation engine uses bridge for weights "testing/", // test helpers create bridge instances "store/", // app store creates bridge "commands/", // commands access bridge via context From 7ae0c9da396ed7a4c996de046df3b5014a08ad3a Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sat, 25 Apr 2026 10:54:14 +0100 Subject: [PATCH 40/41] replace handrolled variation with fontdrasils --- Cargo.lock | 174 +++- .../src/components/VariationPanel.tsx | 1 - .../shift-backends/src/designspace/reader.rs | 3 +- crates/shift-backends/src/glyphs/reader.rs | 5 +- crates/shift-core/Cargo.toml | 1 + crates/shift-core/src/interpolation.rs | 766 +----------------- crates/shift-core/src/snapshot.rs | 23 +- crates/shift-ir/Cargo.toml | 1 + crates/shift-ir/src/axis.rs | 7 + crates/shift-ir/src/font.rs | 27 +- crates/shift-ir/src/lib.rs | 1 + crates/shift-ir/src/variation.rs | 24 + .../__test__/font_integration.spec.mjs | 27 +- crates/shift-node/src/font_engine.rs | 10 +- packages/types/src/generated/GlyphGeometry.ts | 5 + 15 files changed, 287 insertions(+), 788 deletions(-) create mode 100644 crates/shift-ir/src/variation.rs create mode 100644 packages/types/src/generated/GlyphGeometry.ts diff --git a/Cargo.lock b/Cargo.lock index f85b952a..2541747c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -138,6 +138,16 @@ version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "bytes", + "cfg_aliases", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -164,6 +174,12 @@ dependencies = [ "syn", ] +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + [[package]] name = "camino" version = "1.1.10" @@ -211,6 +227,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.41" @@ -517,14 +539,14 @@ dependencies = [ "ansi_term", "chrono", "env_logger", - "fontdrasil", + "fontdrasil 0.2.0", "indexmap", "log", - "ordered-float", + "ordered-float 4.6.0", "serde", - "smol_str", + "smol_str 0.2.2", "thiserror 1.0.69", - "write-fonts", + "write-fonts 0.38.2", ] [[package]] @@ -555,6 +577,16 @@ dependencies = [ "serde", ] +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +dependencies = [ + "bytemuck", + "serde", +] + [[package]] name = "fontbe" version = "0.2.0" @@ -565,19 +597,19 @@ dependencies = [ "chrono", "env_logger", "fea-rs", - "fontdrasil", + "fontdrasil 0.2.0", "fontir", "icu_properties", "indexmap", "kurbo 0.11.2", "log", - "ordered-float", + "ordered-float 4.6.0", "parking_lot", "serde", - "smol_str", + "smol_str 0.2.2", "thiserror 1.0.69", "tinystr", - "write-fonts", + "write-fonts 0.38.2", ] [[package]] @@ -593,7 +625,7 @@ dependencies = [ "env_logger", "filetime", "fontbe", - "fontdrasil", + "fontdrasil 0.2.0", "fontir", "fontra2fontir", "glyphs2fontir", @@ -606,7 +638,7 @@ dependencies = [ "thiserror 1.0.69", "ufo2fontir", "vergen-gitcl", - "write-fonts", + "write-fonts 0.38.2", ] [[package]] @@ -615,10 +647,26 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7486f5a01d9b86a45a53e4d2f46172d731af11e801a3c7c24af30cab055b9d0" dependencies = [ - "ordered-float", + "ordered-float 4.6.0", "serde", - "smol_str", - "write-fonts", + "smol_str 0.2.2", + "write-fonts 0.38.2", +] + +[[package]] +name = "fontdrasil" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa3a7a2fbc9d1817cd07298326376c8bd39868efe7c6149090c4fafea95eaf34" +dependencies = [ + "env_logger", + "kurbo 0.12.0", + "log", + "ordered-float 5.3.0", + "serde", + "smol_str 0.3.6", + "thiserror 2.0.12", + "write-fonts 0.44.1", ] [[package]] @@ -632,17 +680,17 @@ dependencies = [ "chrono", "env_logger", "filetime", - "fontdrasil", + "fontdrasil 0.2.0", "indexmap", "kurbo 0.11.2", "log", - "ordered-float", + "ordered-float 4.6.0", "parking_lot", "serde", "serde_yaml", - "smol_str", + "smol_str 0.2.2", "thiserror 1.0.69", - "write-fonts", + "write-fonts 0.38.2", ] [[package]] @@ -652,16 +700,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4511cace96f3243fef1350e87abf326900ce3a7af6c3646e7cb6e3b26b295baf" dependencies = [ "env_logger", - "fontdrasil", + "fontdrasil 0.2.0", "fontir", "indexmap", "kurbo 0.11.2", "log", - "ordered-float", + "ordered-float 4.6.0", "serde", "serde_json", "thiserror 1.0.69", - "write-fonts", + "write-fonts 0.38.2", ] [[package]] @@ -774,16 +822,16 @@ dependencies = [ "bincode", "chrono", "env_logger", - "fontdrasil", + "fontdrasil 0.2.0", "icu_properties", "indexmap", "kurbo 0.11.2", "log", - "ordered-float", + "ordered-float 4.6.0", "quick-xml", "regex", "serde", - "smol_str", + "smol_str 0.2.2", "thiserror 1.0.69", ] @@ -795,16 +843,16 @@ checksum = "d66204b6a2d374b221a9676b22b64de7a711482dc58f6a13da1c4b20978a3d43" dependencies = [ "chrono", "env_logger", - "fontdrasil", + "fontdrasil 0.2.0", "fontir", "glyphs-reader", "indexmap", "kurbo 0.11.2", "log", - "ordered-float", - "smol_str", + "ordered-float 4.6.0", + "smol_str 0.2.2", "thiserror 1.0.69", - "write-fonts", + "write-fonts 0.38.2", ] [[package]] @@ -988,6 +1036,18 @@ dependencies = [ "smallvec", ] +[[package]] +name = "kurbo" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce9729cc38c18d86123ab736fd2e7151763ba226ac2490ec092d1dd148825e32" +dependencies = [ + "arrayvec", + "euclid", + "serde", + "smallvec", +] + [[package]] name = "kurbo" version = "0.13.0" @@ -1235,6 +1295,17 @@ dependencies = [ "serde", ] +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", + "rand", + "serde", +] + [[package]] name = "parking_lot" version = "0.12.4" @@ -1396,7 +1467,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04ca636dac446b5664bd16c069c00a9621806895b8bb02c2dc68542b23b8f25d" dependencies = [ "bytemuck", - "font-types", + "font-types 0.9.0", "serde", ] @@ -1407,7 +1478,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "192735ef611aac958468e670cb98432c925426f3cb71521fda202130f7388d91" dependencies = [ "bytemuck", - "font-types", + "font-types 0.9.0", +] + +[[package]] +name = "read-fonts" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eaa2941a4c05443ee3a7b26ab076a553c343ad5995230cc2b1d3e993bdc6345" +dependencies = [ + "bytemuck", + "font-types 0.10.1", + "serde", ] [[package]] @@ -1587,6 +1669,7 @@ version = "0.0.0" dependencies = [ "bitflags", "fontc", + "fontdrasil 0.4.0", "norad 0.16.0", "serde", "serde_json", @@ -1601,6 +1684,7 @@ dependencies = [ name = "shift-ir" version = "0.1.0" dependencies = [ + "fontdrasil 0.4.0", "indexmap", "kurbo 0.13.0", "linesweeper", @@ -1660,6 +1744,16 @@ dependencies = [ "serde", ] +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -1839,18 +1933,18 @@ checksum = "6d6a943c02f505748a2d076447518ef08fdd6fa7d6b12fd6352681fb873c5c27" dependencies = [ "chrono", "env_logger", - "fontdrasil", + "fontdrasil 0.2.0", "fontir", "indexmap", "kurbo 0.11.2", "log", "norad 0.15.0", - "ordered-float", + "ordered-float 4.6.0", "plist", "serde", "serde_yaml", "thiserror 1.0.69", - "write-fonts", + "write-fonts 0.38.2", ] [[package]] @@ -2180,7 +2274,7 @@ version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60491c1d7e5873e721607fa05fd9b9fc66b0f0179b2b5f0f26a2022aafdcc61e" dependencies = [ - "font-types", + "font-types 0.9.0", "indexmap", "kurbo 0.11.2", "log", @@ -2188,6 +2282,20 @@ dependencies = [ "serde", ] +[[package]] +name = "write-fonts" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "187aca250ee00f9d9ad3a1ebc9ed65c9c921fdd4690b095ca6e5a8df7246d871" +dependencies = [ + "font-types 0.10.1", + "indexmap", + "kurbo 0.12.0", + "log", + "read-fonts 0.36.0", + "serde", +] + [[package]] name = "writeable" version = "0.6.1" diff --git a/apps/desktop/src/renderer/src/components/VariationPanel.tsx b/apps/desktop/src/renderer/src/components/VariationPanel.tsx index 3deeeece..7fc2c639 100644 --- a/apps/desktop/src/renderer/src/components/VariationPanel.tsx +++ b/apps/desktop/src/renderer/src/components/VariationPanel.tsx @@ -5,7 +5,6 @@ import { getEditor } from "@/store/store"; import { useSignalState } from "@/lib/reactive"; import { interpolateGlyph, type MasterSnapshot } from "@/lib/interpolation/interpolate"; -/** Variation axis slider panel — shown when a variable font is loaded. */ export const VariationPanel = () => { const editor = getEditor(); const font = editor.font; diff --git a/crates/shift-backends/src/designspace/reader.rs b/crates/shift-backends/src/designspace/reader.rs index 89186441..692297dd 100644 --- a/crates/shift-backends/src/designspace/reader.rs +++ b/crates/shift-backends/src/designspace/reader.rs @@ -68,12 +68,13 @@ impl FontReader for DesignspaceReader { let default_layer_id = font.default_layer_id(); let default_location = location_from_dimensions(&default_ds_source.location, &doc); let default_name = source_name(default_ds_source, default_idx); - font.add_source(Source::with_filename( + let default_source_id = font.add_source(Source::with_filename( default_name, default_location, default_layer_id, default_ds_source.filename.clone(), )); + font.set_default_source_id(default_source_id); // Cache loaded UFO fonts so we don't re-read the same file for support layers. let mut ufo_cache: HashMap = HashMap::new(); diff --git a/crates/shift-backends/src/glyphs/reader.rs b/crates/shift-backends/src/glyphs/reader.rs index 351f7218..b9f0032b 100644 --- a/crates/shift-backends/src/glyphs/reader.rs +++ b/crates/shift-backends/src/glyphs/reader.rs @@ -196,7 +196,10 @@ impl FontReader for GlyphsReader { location.set(axis.tag.clone(), value.into_inner()); } } - font.add_source(Source::new(master.name.clone(), location, layer_id)); + let source_id = font.add_source(Source::new(master.name.clone(), location, layer_id)); + if master_idx == glyphs_font.default_master_idx { + font.set_default_source_id(source_id); + } } for glyph in glyphs_font.glyphs.values() { diff --git a/crates/shift-core/Cargo.toml b/crates/shift-core/Cargo.toml index 0dcc6033..28cb1cab 100644 --- a/crates/shift-core/Cargo.toml +++ b/crates/shift-core/Cargo.toml @@ -9,6 +9,7 @@ crate-type = ["rlib"] [dependencies] bitflags = "2.9.1" fontc = "0.2.0" +fontdrasil = "0.4.0" norad = "0.16.0" serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0" diff --git a/crates/shift-core/src/interpolation.rs b/crates/shift-core/src/interpolation.rs index 9ee5e536..e381cb8b 100644 --- a/crates/shift-core/src/interpolation.rs +++ b/crates/shift-core/src/interpolation.rs @@ -1,17 +1,19 @@ use std::collections::{HashMap, HashSet}; +use std::str::FromStr; +use fontdrasil::coords::NormalizedLocation; +use fontdrasil::types::Tag; +use fontdrasil::variations::VariationModel; use serde::{Deserialize, Serialize}; +use shift_ir::variation::to_fd_location; +use shift_ir::{Axis, Location}; -use crate::snapshot::{GlyphSnapshot, MasterSnapshot}; -use crate::{Axis, Location}; - -type SparseLocation = HashMap; -type Support = HashMap; +use crate::snapshot::{GlyphGeometry, MasterSnapshot}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct InterpolationResult { - pub instance: GlyphSnapshot, + pub geometry: GlyphGeometry, pub errors: Vec, } @@ -23,241 +25,22 @@ pub struct SourceError { pub message: String, } -struct VariationModelData { - mapping: Vec, - supports: Vec, - delta_weights: Vec>, -} - -// --- Normalization --- - -fn normalize_axis_value(value: f64, axis: &Axis) -> f64 { - if value < axis.default() { - let range = axis.default() - axis.minimum(); - if range.abs() < f64::EPSILON { - return 0.0; - } - (value - axis.default()) / range - } else if value > axis.default() { - let range = axis.maximum() - axis.default(); - if range.abs() < f64::EPSILON { - return 0.0; - } - (value - axis.default()) / range - } else { - 0.0 - } -} - -fn normalize_location(location: &Location, axes: &[Axis]) -> SparseLocation { - let mut result = SparseLocation::new(); - for axis in axes { - let value = location.get(axis.tag()).unwrap_or(axis.default()); - let n = normalize_axis_value(value, axis); - if n.abs() > 1e-14 { - result.insert(axis.tag().to_string(), n); - } - } - result -} - -// --- Support scalar --- - -fn support_scalar(location: &SparseLocation, support: &Support) -> f64 { - let mut scalar = 1.0; - for (tag, &(lower, peak, upper)) in support { - let loc_val = location.get(tag).copied().unwrap_or(0.0); - if peak.abs() < f64::EPSILON { - if loc_val.abs() > f64::EPSILON { - return 0.0; - } - continue; - } - if loc_val < lower || loc_val > upper { - return 0.0; - } - if (loc_val - peak).abs() < f64::EPSILON { - continue; - } - if loc_val < peak { - if (peak - lower).abs() < f64::EPSILON { - return 0.0; - } - scalar *= (loc_val - lower) / (peak - lower); - } else { - if (upper - peak).abs() < f64::EPSILON { - return 0.0; - } - scalar *= (upper - loc_val) / (upper - peak); - } - } - scalar -} - -// --- VariationModel construction --- -// Ported from fontTools varLib.models.VariationModel._supports() - -/// Build support regions for each master location. -/// -/// Each support is a sparse map of axis tag → (lower, peak, upper) tent. -/// Only axes where the master deviates from the default (peak != 0) are included. -/// The default master (at origin) has an EMPTY support, giving scalar 1.0 everywhere. -fn build_supports(sorted_locations: &[SparseLocation]) -> Vec { - let mut supports = Vec::with_capacity(sorted_locations.len()); - - for (i, loc) in sorted_locations.iter().enumerate() { - let mut min_v: HashMap = HashMap::new(); - let mut max_v: HashMap = HashMap::new(); - - // Look at previous locations that share the same set of axes - for prev_loc in &sorted_locations[..i] { - let loc_keys: HashSet<&String> = loc.keys().collect(); - let prev_keys: HashSet<&String> = prev_loc.keys().collect(); - if loc_keys != prev_keys { - continue; - } - - for (axis, &val) in prev_loc { - let loc_val = loc.get(axis).copied().unwrap_or(0.0); - // Only consider previous locations on the same side of origin - if val * loc_val > 0.0 { - if val > loc_val { - let entry = min_v.entry(axis.clone()).or_insert(val); - if val < *entry { - *entry = val; - } - } else if val < loc_val { - let entry = max_v.entry(axis.clone()).or_insert(val); - if val > *entry { - *entry = val; - } - } - } - } - } - - let mut support = Support::new(); - for (axis, &val) in loc { - let (lower, upper) = if val > 0.0 { - ( - *min_v.get(axis).unwrap_or(&0.0), - *max_v.get(axis).unwrap_or(&val), - ) - } else { - ( - *min_v.get(axis).unwrap_or(&val), - *max_v.get(axis).unwrap_or(&0.0), - ) - }; - support.insert(axis.clone(), (lower, val, upper)); - } - - supports.push(support); - } - - supports -} - -fn build_variation_model( - locations: &[SparseLocation], - axis_order: &[String], -) -> VariationModelData { - let n = locations.len(); - - let all_axis_points: HashMap> = { - let mut map: HashMap> = HashMap::new(); - for loc in locations { - for (tag, &val) in loc { - map.entry(tag.clone()) - .or_default() - .insert((val * 1e9) as i64); - } - } - map - }; - - let axis_index = |tag: &str| -> usize { - axis_order - .iter() - .position(|t| t == tag) - .unwrap_or(usize::MAX) - }; - - type SortKey = (usize, usize, usize, Vec<(usize, i64, i64)>); - - // Decorate each location for sorting - let mut decorated: Vec = locations - .iter() - .enumerate() - .map(|(orig_idx, loc)| { - let rank = loc.len(); - let on_point_axes = loc - .iter() - .filter(|(tag, _)| all_axis_points.get(*tag).is_some_and(|pts| pts.len() == 1)) - .count(); - - let mut axis_keys: Vec<(usize, i64, i64)> = loc - .iter() - .map(|(tag, &val)| { - let idx = axis_index(tag); - let sign = if val > 0.0 { 0 } else { 1 }; - let magnitude = (val.abs() * 1e9) as i64; - (idx, sign, magnitude) - }) - .collect(); - axis_keys.sort(); - - (orig_idx, rank, on_point_axes, axis_keys) - }) - .collect(); - - decorated.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.cmp(&b.2)).then(a.3.cmp(&b.3))); - - let mapping: Vec = decorated.iter().map(|(orig, _, _, _)| *orig).collect(); - let sorted_locations: Vec = - mapping.iter().map(|&i| locations[i].clone()).collect(); - - let supports = build_supports(&sorted_locations); - - // Compute delta weights - let delta_weights: Vec> = (0..n) - .map(|i| { - let mut weights = Vec::new(); - for (j, support) in supports.iter().enumerate().take(i) { - let scalar = support_scalar(&sorted_locations[i], support); - if scalar.abs() > f64::EPSILON { - weights.push((j, scalar)); - } - } - weights - }) - .collect(); - - VariationModelData { - mapping, - supports, - delta_weights, - } -} - -// --- Snapshot flattening --- - -fn flatten_snapshot(snap: &GlyphSnapshot) -> Vec { - let mut values = vec![snap.x_advance]; - for contour in &snap.contours { +fn flatten(geom: &GlyphGeometry) -> Vec { + let mut values = vec![geom.x_advance]; + for contour in &geom.contours { for point in &contour.points { values.push(point.x); values.push(point.y); } } - for anchor in &snap.anchors { + for anchor in &geom.anchors { values.push(anchor.x); values.push(anchor.y); } values } -fn reconstruct_snapshot(template: &GlyphSnapshot, values: &[f64]) -> GlyphSnapshot { +fn reconstruct(template: &GlyphGeometry, values: &[f64]) -> GlyphGeometry { let mut result = template.clone(); let mut idx = 0; @@ -283,7 +66,7 @@ fn reconstruct_snapshot(template: &GlyphSnapshot, values: &[f64]) -> GlyphSnapsh result } -fn check_compatibility(a: &GlyphSnapshot, b: &GlyphSnapshot) -> Result<(), String> { +fn check_compatibility(a: &GlyphGeometry, b: &GlyphGeometry) -> Result<(), String> { if a.contours.len() != b.contours.len() { return Err(format!( "contour count mismatch: {} vs {}", @@ -311,35 +94,6 @@ fn check_compatibility(a: &GlyphSnapshot, b: &GlyphSnapshot) -> Result<(), Strin Ok(()) } -// --- Itemwise arithmetic --- - -fn sub_values(a: &[f64], b: &[f64]) -> Vec { - a.iter().zip(b.iter()).map(|(x, y)| x - y).collect() -} - -fn add_values(a: &[f64], b: &[f64]) -> Vec { - a.iter().zip(b.iter()).map(|(x, y)| x + y).collect() -} - -fn mul_scalar_values(a: &[f64], s: f64) -> Vec { - a.iter().map(|x| x * s).collect() -} - -fn zero_values(len: usize) -> Vec { - vec![0.0; len] -} - -// --- Main entry point --- - -fn location_to_key(loc: &SparseLocation) -> String { - let mut keys: Vec<_> = loc.iter().collect(); - keys.sort_by(|a, b| a.0.cmp(b.0)); - keys.iter() - .map(|(k, v)| format!("{k}:{v:.10}")) - .collect::>() - .join(",") -} - pub fn interpolate_glyph( masters: &[MasterSnapshot], axes: &[Axis], @@ -349,490 +103,40 @@ pub fn interpolate_glyph( return None; } - let axis_order: Vec = axes.iter().map(|a| a.tag().to_string()).collect(); - - // Normalize all master locations - let normalized_masters: Vec<(usize, SparseLocation)> = masters + let ordered_axes: Vec = axes .iter() - .enumerate() - .map(|(i, m)| (i, normalize_location(&m.location, axes))) - .collect(); - - // Check for a default master (at origin) - let has_default = normalized_masters.iter().any(|(_, loc)| loc.is_empty()); - if !has_default { - return None; - } - - // Deduplicate by normalized location - let mut seen = HashSet::new(); - let deduped: Vec<(usize, SparseLocation)> = normalized_masters - .into_iter() - .filter(|(_, loc)| seen.insert(location_to_key(loc))) + .filter_map(|a| Tag::from_str(a.tag()).ok()) .collect(); - if deduped.len() < 2 { - return None; - } - - // Find the default master (first at empty location) - let default_idx = deduped.iter().position(|(_, loc)| loc.is_empty()).unwrap(); - let default_master = &masters[deduped[default_idx].0]; - - // Build the variation model from deduplicated locations - let model_locations: Vec = deduped.iter().map(|(_, loc)| loc.clone()).collect(); - let model = build_variation_model(&model_locations, &axis_order); - - // Flatten the default master to get the value length - let default_values = flatten_snapshot(&default_master.snapshot); - let value_len = default_values.len(); - - // Compute deltas for each sorted master - let mut deltas: Vec> = Vec::with_capacity(model.mapping.len()); - let mut errors: Vec = Vec::new(); - - for (sorted_idx, &orig_model_idx) in model.mapping.iter().enumerate() { - let master_idx = deduped[orig_model_idx].0; - let master = &masters[master_idx]; + let default_master = masters + .iter() + .find(|master| master.location.is_default_axis(axes))?; - // Check compatibility with default - match check_compatibility(&default_master.snapshot, &master.snapshot) { + let mut errors = Vec::new(); + let mut points: HashMap> = HashMap::new(); + for (source_index, master) in masters.iter().enumerate() { + match check_compatibility(&master.geometry, &default_master.geometry) { Ok(()) => { - let master_values = flatten_snapshot(&master.snapshot); - // delta = master_values - sum(delta_weights[j] * deltas[j]) - let mut delta = sub_values(&master_values, &zero_values(value_len)); - for &(prev_sorted, weight) in &model.delta_weights[sorted_idx] { - let contribution = mul_scalar_values(&deltas[prev_sorted], weight); - delta = sub_values(&delta, &contribution); - } - deltas.push(delta); + let loc = to_fd_location(&master.location, axes); + points.insert(loc, flatten(&master.geometry)); } - Err(msg) => { + Err(message) => { errors.push(SourceError { - source_index: master_idx, + source_index, source_name: master.source_name.clone(), - message: msg, + message, }); - deltas.push(zero_values(value_len)); } } } - // Interpolate at target location - let target_normalized = normalize_location(target, axes); - let mut result_values = zero_values(value_len); - let mut has_contribution = false; - - for (sorted_idx, support) in model.supports.iter().enumerate() { - let scalar = support_scalar(&target_normalized, support); - if scalar.abs() < f64::EPSILON { - continue; - } - let contribution = mul_scalar_values(&deltas[sorted_idx], scalar); - result_values = add_values(&result_values, &contribution); - has_contribution = true; - } - - if !has_contribution { - return Some(InterpolationResult { - instance: default_master.snapshot.clone(), - errors, - }); - } - - let instance = reconstruct_snapshot(&default_master.snapshot, &result_values); - - Some(InterpolationResult { instance, errors }) -} + let locations_set: HashSet = points.keys().cloned().collect(); + let model = VariationModel::new(locations_set, ordered_axes); + let deltas = model.deltas::(&points).ok()?; -#[cfg(test)] -mod tests { - use super::*; - use crate::snapshot::{ - AnchorSnapshot, ContourSnapshot, GlyphSnapshot, MasterSnapshot, PointSnapshot, PointType, - }; - use crate::{Axis, Location}; + let target_fd = to_fd_location(target, axes); + let result_values: Vec = model.interpolate_from_deltas(&target_fd, &deltas); + let geometry = reconstruct(&default_master.geometry, &result_values); - fn make_axis(tag: &str, name: &str, min: f64, default: f64, max: f64) -> Axis { - Axis::new(tag.to_string(), name.to_string(), min, default, max) - } - - fn make_point(id: &str, x: f64, y: f64) -> PointSnapshot { - PointSnapshot { - id: id.to_string(), - x, - y, - point_type: PointType::OnCurve, - smooth: false, - } - } - - fn make_contour(id: &str, points: Vec) -> ContourSnapshot { - ContourSnapshot { - id: id.to_string(), - points, - closed: true, - } - } - - fn make_snapshot(contours: Vec, x_advance: f64) -> GlyphSnapshot { - GlyphSnapshot { - unicode: 65, - name: "A".to_string(), - x_advance, - contours, - anchors: Vec::new(), - composite_contours: Vec::new(), - active_contour_id: None, - } - } - - fn make_master(name: &str, location: Location, snapshot: GlyphSnapshot) -> MasterSnapshot { - MasterSnapshot { - source_id: name.to_string(), - source_name: name.to_string(), - location, - snapshot, - } - } - - // --- normalize_axis_value tests --- - - #[test] - fn normalize_returns_zero_at_default() { - let axis = make_axis("wght", "Weight", 100.0, 400.0, 900.0); - assert!((normalize_axis_value(400.0, &axis)).abs() < f64::EPSILON); - } - - #[test] - fn normalize_returns_neg_one_at_min() { - let axis = make_axis("wght", "Weight", 100.0, 400.0, 900.0); - assert!((normalize_axis_value(100.0, &axis) - (-1.0)).abs() < 0.001); - } - - #[test] - fn normalize_returns_one_at_max() { - let axis = make_axis("wght", "Weight", 100.0, 400.0, 900.0); - assert!((normalize_axis_value(900.0, &axis) - 1.0).abs() < 0.001); - } - - #[test] - fn normalize_midpoint() { - let axis = make_axis("wght", "Weight", 100.0, 400.0, 900.0); - assert!((normalize_axis_value(650.0, &axis) - 0.5).abs() < 0.001); - } - - // --- check_compatibility tests --- - - #[test] - fn compatible_masters_pass() { - let a = make_snapshot( - vec![make_contour( - "c1", - vec![make_point("p1", 0.0, 0.0), make_point("p2", 100.0, 0.0)], - )], - 500.0, - ); - let b = make_snapshot( - vec![make_contour( - "c1", - vec![make_point("p1", 0.0, 0.0), make_point("p2", 200.0, 0.0)], - )], - 600.0, - ); - assert!(check_compatibility(&a, &b).is_ok()); - } - - #[test] - fn incompatible_contour_count() { - let a = make_snapshot( - vec![make_contour("c1", vec![make_point("p1", 0.0, 0.0)])], - 500.0, - ); - let b = make_snapshot(Vec::new(), 500.0); - assert!(check_compatibility(&a, &b).is_err()); - } - - #[test] - fn incompatible_point_count() { - let a = make_snapshot( - vec![make_contour( - "c1", - vec![make_point("p1", 0.0, 0.0), make_point("p2", 100.0, 0.0)], - )], - 500.0, - ); - let b = make_snapshot( - vec![make_contour("c1", vec![make_point("p1", 0.0, 0.0)])], - 500.0, - ); - assert!(check_compatibility(&a, &b).is_err()); - } - - // --- interpolate_glyph tests --- - - fn two_master_setup() -> (Vec, Vec) { - let axes = vec![make_axis("wght", "Weight", 0.0, 0.0, 1000.0)]; - - let light = make_master( - "Light", - Location::new(), - make_snapshot( - vec![make_contour( - "c1", - vec![ - make_point("p1", 0.0, 0.0), - make_point("p2", 100.0, 0.0), - make_point("p3", 100.0, 100.0), - ], - )], - 400.0, - ), - ); - - let mut bold_loc = Location::new(); - bold_loc.set("wght".to_string(), 1000.0); - let bold = make_master( - "Bold", - bold_loc, - make_snapshot( - vec![make_contour( - "c1", - vec![ - make_point("p1", 0.0, 0.0), - make_point("p2", 200.0, 0.0), - make_point("p3", 200.0, 200.0), - ], - )], - 600.0, - ), - ); - - (vec![light, bold], axes) - } - - #[test] - fn interpolate_midpoint() { - let (masters, axes) = two_master_setup(); - let mut target = Location::new(); - target.set("wght".to_string(), 500.0); - - let result = interpolate_glyph(&masters, &axes, &target).unwrap(); - - assert!((result.instance.x_advance - 500.0).abs() < 0.01); - assert!((result.instance.contours[0].points[1].x - 150.0).abs() < 0.01); - assert!((result.instance.contours[0].points[2].y - 150.0).abs() < 0.01); - assert!(result.errors.is_empty()); - } - - #[test] - fn interpolate_at_default_returns_default() { - let (masters, axes) = two_master_setup(); - let target = Location::new(); // default = wght 0 - - let result = interpolate_glyph(&masters, &axes, &target).unwrap(); - - assert!((result.instance.x_advance - 400.0).abs() < 0.01); - assert!((result.instance.contours[0].points[1].x - 100.0).abs() < 0.01); - } - - #[test] - fn interpolate_at_master_returns_master() { - let (masters, axes) = two_master_setup(); - let mut target = Location::new(); - target.set("wght".to_string(), 1000.0); - - let result = interpolate_glyph(&masters, &axes, &target).unwrap(); - - assert!((result.instance.x_advance - 600.0).abs() < 0.01); - assert!((result.instance.contours[0].points[1].x - 200.0).abs() < 0.01); - } - - #[test] - fn preserves_point_metadata() { - let (masters, axes) = two_master_setup(); - let mut target = Location::new(); - target.set("wght".to_string(), 500.0); - - let result = interpolate_glyph(&masters, &axes, &target).unwrap(); - - assert_eq!(result.instance.contours[0].points[0].id, "p1"); - assert_eq!(result.instance.contours[0].points[1].id, "p2"); - assert_eq!(result.instance.contours[0].id, "c1"); - assert!(matches!( - result.instance.contours[0].points[0].point_type, - PointType::OnCurve - )); - } - - #[test] - fn two_axis_four_master_interpolation() { - let axes = vec![ - make_axis("wdth", "Width", 0.0, 0.0, 1000.0), - make_axis("wght", "Weight", 0.0, 0.0, 1000.0), - ]; - - let make_single = |x: f64, adv: f64| -> GlyphSnapshot { - make_snapshot( - vec![make_contour("c1", vec![make_point("p1", x, 0.0)])], - adv, - ) - }; - - let m1 = make_master("LightCondensed", Location::new(), make_single(100.0, 400.0)); - - let mut loc2 = Location::new(); - loc2.set("wght".to_string(), 1000.0); - let m2 = make_master("BoldCondensed", loc2, make_single(200.0, 600.0)); - - let mut loc3 = Location::new(); - loc3.set("wdth".to_string(), 1000.0); - let m3 = make_master("LightWide", loc3, make_single(300.0, 800.0)); - - let mut loc4 = Location::new(); - loc4.set("wdth".to_string(), 1000.0); - loc4.set("wght".to_string(), 1000.0); - let m4 = make_master("BoldWide", loc4, make_single(400.0, 1000.0)); - - let masters = vec![m1, m2, m3, m4]; - let mut target = Location::new(); - target.set("wdth".to_string(), 500.0); - target.set("wght".to_string(), 500.0); - - let result = interpolate_glyph(&masters, &axes, &target).unwrap(); - - // Midpoint of all four: (100+200+300+400)/4 = 250 - assert!((result.instance.contours[0].points[0].x - 250.0).abs() < 0.01); - } - - #[test] - fn returns_none_for_single_master() { - let axes = vec![make_axis("wght", "Weight", 0.0, 0.0, 1000.0)]; - let master = make_master("Only", Location::new(), make_snapshot(vec![], 500.0)); - - let result = interpolate_glyph(&[master], &axes, &Location::new()); - assert!(result.is_none()); - } - - #[test] - fn returns_none_without_default_master() { - let axes = vec![make_axis("wght", "Weight", 0.0, 0.0, 1000.0)]; - - let mut loc1 = Location::new(); - loc1.set("wght".to_string(), 500.0); - let mut loc2 = Location::new(); - loc2.set("wght".to_string(), 1000.0); - - let m1 = make_master("A", loc1, make_snapshot(vec![], 500.0)); - let m2 = make_master("B", loc2, make_snapshot(vec![], 600.0)); - - let result = interpolate_glyph(&[m1, m2], &axes, &Location::new()); - assert!(result.is_none()); - } - - #[test] - fn incompatible_source_reports_error() { - let axes = vec![make_axis("wght", "Weight", 0.0, 0.0, 1000.0)]; - - let default_snap = make_snapshot( - vec![make_contour( - "c1", - vec![make_point("p1", 0.0, 0.0), make_point("p2", 100.0, 0.0)], - )], - 400.0, - ); - - // Incompatible: different point count - let bad_snap = make_snapshot( - vec![make_contour("c1", vec![make_point("p1", 0.0, 0.0)])], - 600.0, - ); - - let m1 = make_master("Default", Location::new(), default_snap); - let mut loc2 = Location::new(); - loc2.set("wght".to_string(), 1000.0); - let m2 = make_master("Bad", loc2, bad_snap); - - let mut target = Location::new(); - target.set("wght".to_string(), 500.0); - - let result = interpolate_glyph(&[m1, m2], &axes, &target).unwrap(); - - assert_eq!(result.errors.len(), 1); - assert_eq!(result.errors[0].source_name, "Bad"); - // Should return default since incompatible master is zeroed - assert!((result.instance.x_advance - 400.0).abs() < 0.01); - } - - #[test] - fn deduplicates_same_location() { - let axes = vec![make_axis("wght", "Weight", 0.0, 0.0, 1000.0)]; - - let snap1 = make_snapshot( - vec![make_contour("c1", vec![make_point("p1", 100.0, 0.0)])], - 400.0, - ); - let snap2 = snap1.clone(); - - let mut bold_loc = Location::new(); - bold_loc.set("wght".to_string(), 1000.0); - let snap3 = make_snapshot( - vec![make_contour("c1", vec![make_point("p1", 200.0, 0.0)])], - 600.0, - ); - - let masters = vec![ - make_master("Default1", Location::new(), snap1), - make_master("Default2", Location::new(), snap2), - make_master("Bold", bold_loc, snap3), - ]; - - let mut target = Location::new(); - target.set("wght".to_string(), 500.0); - - let result = interpolate_glyph(&masters, &axes, &target).unwrap(); - assert!((result.instance.contours[0].points[0].x - 150.0).abs() < 0.01); - } - - #[test] - fn interpolates_anchors() { - let axes = vec![make_axis("wght", "Weight", 0.0, 0.0, 1000.0)]; - - let mut snap_light = make_snapshot( - vec![make_contour("c1", vec![make_point("p1", 100.0, 0.0)])], - 400.0, - ); - snap_light.anchors.push(AnchorSnapshot { - id: "a1".to_string(), - name: Some("top".to_string()), - x: 250.0, - y: 700.0, - }); - - let mut snap_bold = make_snapshot( - vec![make_contour("c1", vec![make_point("p1", 200.0, 0.0)])], - 600.0, - ); - snap_bold.anchors.push(AnchorSnapshot { - id: "a1".to_string(), - name: Some("top".to_string()), - x: 300.0, - y: 800.0, - }); - - let mut bold_loc = Location::new(); - bold_loc.set("wght".to_string(), 1000.0); - - let masters = vec![ - make_master("Light", Location::new(), snap_light), - make_master("Bold", bold_loc, snap_bold), - ]; - - let mut target = Location::new(); - target.set("wght".to_string(), 500.0); - - let result = interpolate_glyph(&masters, &axes, &target).unwrap(); - assert!((result.instance.anchors[0].x - 275.0).abs() < 0.01); - assert!((result.instance.anchors[0].y - 750.0).abs() < 0.01); - } + Some(InterpolationResult { geometry, errors }) } diff --git a/crates/shift-core/src/snapshot.rs b/crates/shift-core/src/snapshot.rs index da45da6d..99d66fc5 100644 --- a/crates/shift-core/src/snapshot.rs +++ b/crates/shift-core/src/snapshot.rs @@ -131,6 +131,26 @@ impl From<&Contour> for RenderContourSnapshot { } } +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../../packages/types/src/generated/")] +pub struct GlyphGeometry { + #[ts(rename = "xAdvance")] + pub x_advance: f64, + pub contours: Vec, + pub anchors: Vec, +} + +impl From<&GlyphSnapshot> for GlyphGeometry { + fn from(snap: &GlyphSnapshot) -> Self { + Self { + x_advance: snap.x_advance, + contours: snap.contours.clone(), + anchors: snap.anchors.clone(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../../../packages/types/src/generated/")] @@ -227,8 +247,9 @@ impl CommandResult { pub struct MasterSnapshot { pub source_id: String, pub source_name: String, + pub is_default_source: bool, pub location: Location, - pub snapshot: GlyphSnapshot, + pub geometry: GlyphGeometry, } #[cfg(test)] diff --git a/crates/shift-ir/Cargo.toml b/crates/shift-ir/Cargo.toml index c44e7e03..706c6f48 100644 --- a/crates/shift-ir/Cargo.toml +++ b/crates/shift-ir/Cargo.toml @@ -9,6 +9,7 @@ license = "MIT OR Apache-2.0" crate-type = ["rlib"] [dependencies] +fontdrasil = "0.4.0" indexmap = { version = "2", features = ["serde"] } kurbo = "0.13.0" linesweeper = "0.3.0" diff --git a/crates/shift-ir/src/axis.rs b/crates/shift-ir/src/axis.rs index 82978f72..d7b5bdfc 100644 --- a/crates/shift-ir/src/axis.rs +++ b/crates/shift-ir/src/axis.rs @@ -137,6 +137,13 @@ impl Location { } Location::from_map(normalized) } + + pub fn is_default_axis(&self, axes: &[Axis]) -> bool { + axes.iter().all(|axis| { + let value = self.get(axis.tag()).unwrap_or(axis.default()); + (value - axis.default()).abs() < f64::EPSILON + }) + } } #[cfg(test)] diff --git a/crates/shift-ir/src/font.rs b/crates/shift-ir/src/font.rs index 974f38a9..85d95a2f 100644 --- a/crates/shift-ir/src/font.rs +++ b/crates/shift-ir/src/font.rs @@ -1,5 +1,5 @@ use crate::axis::Axis; -use crate::entity::LayerId; +use crate::entity::{LayerId, SourceId}; use crate::features::FeatureData; use crate::glyph::Glyph; use crate::guideline::Guideline; @@ -83,6 +83,8 @@ pub struct Font { metrics: FontMetrics, axes: Vec, sources: Vec, + #[serde(default)] + default_source_id: Option, layers: HashMap, glyphs: HashMap, kerning: KerningData, @@ -103,6 +105,7 @@ impl Default for Font { metrics: FontMetrics::default(), axes: Vec::new(), sources: Vec::new(), + default_source_id: None, layers, glyphs: HashMap::new(), kerning: KerningData::new(), @@ -147,8 +150,28 @@ impl Font { &self.sources } - pub fn add_source(&mut self, source: Source) { + pub fn add_source(&mut self, source: Source) -> SourceId { + let source_id = source.id(); + if self.default_source_id.is_none() { + self.default_source_id = Some(source_id); + } self.sources.push(source); + source_id + } + + pub fn default_source_id(&self) -> Option { + self.default_source_id + } + + pub fn set_default_source_id(&mut self, source_id: SourceId) { + self.default_source_id = Some(source_id); + } + + pub fn default_source(&self) -> Option<&Source> { + let default_source_id = self.default_source_id?; + self.sources + .iter() + .find(|source| source.id() == default_source_id) } pub fn is_variable(&self) -> bool { diff --git a/crates/shift-ir/src/lib.rs b/crates/shift-ir/src/lib.rs index a4e911c4..b22c658f 100644 --- a/crates/shift-ir/src/lib.rs +++ b/crates/shift-ir/src/lib.rs @@ -15,6 +15,7 @@ mod metrics; mod point; mod segment; mod source; +pub mod variation; pub use anchor::Anchor; pub use axis::{Axis, Location}; diff --git a/crates/shift-ir/src/variation.rs b/crates/shift-ir/src/variation.rs new file mode 100644 index 00000000..59851249 --- /dev/null +++ b/crates/shift-ir/src/variation.rs @@ -0,0 +1,24 @@ +use std::str::FromStr; + +use fontdrasil::{ + coords::{NormalizedCoord, NormalizedLocation}, + types::Tag, +}; + +use crate::{Axis, Location}; + +pub fn to_fd_location(loc: &Location, axes: &[Axis]) -> NormalizedLocation { + let mut result = NormalizedLocation::new(); + + for axis in axes { + let value = loc.get(axis.tag()).unwrap_or(axis.default()); + let n = axis.normalize(value); + let Ok(tag) = Tag::from_str(axis.tag()) else { + continue; + }; + + result.insert(tag, NormalizedCoord::new(n)); + } + + result +} diff --git a/crates/shift-node/__test__/font_integration.spec.mjs b/crates/shift-node/__test__/font_integration.spec.mjs index f56324d0..e6d08828 100644 --- a/crates/shift-node/__test__/font_integration.spec.mjs +++ b/crates/shift-node/__test__/font_integration.spec.mjs @@ -23,7 +23,6 @@ function startEditSessionByUnicode(engine, unicode) { describe("FontEngine Integration - UFO Loading", () => { it("loads MutatorSans UFO successfully", () => { if (!existsSync(MUTATORSANS_UFO)) { - console.log("Skipping: MutatorSans UFO not found"); return; } @@ -232,7 +231,6 @@ describe("FontEngine Integration - Round Trip", () => { describe("FontEngine Integration - TTF Loading", () => { it("loads MutatorSans TTF successfully", () => { if (!existsSync(MUTATORSANS_TTF)) { - console.log("Skipping: MutatorSans TTF not found"); return; } @@ -540,18 +538,21 @@ describe("FontEngine Integration - Variable Font (.glyphs)", () => { const masters = JSON.parse(json); expect(masters).toHaveLength(2); - // Each master snapshot has sourceId, sourceName, location, snapshot + // Each master snapshot carries source metadata plus interpolation geometry. for (const m of masters) { expect(m).toHaveProperty("sourceId"); expect(m).toHaveProperty("sourceName"); expect(m).toHaveProperty("location"); - expect(m).toHaveProperty("snapshot"); - expect(m.snapshot.contours).toHaveLength(2); + expect(m).toHaveProperty("isDefaultSource"); + expect(m).toHaveProperty("geometry"); + expect(m).not.toHaveProperty("snapshot"); + expect(m.geometry.contours).toHaveLength(2); + expect(m.geometry.xAdvance).toBeGreaterThan(0); } // Both masters should have matching total point counts - const lightTotal = masters[0].snapshot.contours.reduce((s, c) => s + c.points.length, 0); - const boldTotal = masters[1].snapshot.contours.reduce((s, c) => s + c.points.length, 0); + const lightTotal = masters[0].geometry.contours.reduce((s, c) => s + c.points.length, 0); + const boldTotal = masters[1].geometry.contours.reduce((s, c) => s + c.points.length, 0); expect(lightTotal).toBe(boldTotal); }); @@ -612,8 +613,12 @@ describe("FontEngine Integration - Designspace", () => { expect(json).not.toBeNull(); const masters = JSON.parse(json); expect(masters.length).toBeGreaterThanOrEqual(4); + expect(masters.filter((m) => m.isDefaultSource)).toHaveLength(1); for (const m of masters) { - expect(m.snapshot.contours.length).toBeGreaterThan(0); + expect(m).toHaveProperty("geometry"); + expect(m).not.toHaveProperty("snapshot"); + expect(m.geometry.contours.length).toBeGreaterThan(0); + expect(m.geometry.xAdvance).toBeGreaterThan(0); } }); @@ -623,7 +628,7 @@ describe("FontEngine Integration - Designspace", () => { const json = engine.getGlyphMasterSnapshots("A"); const masters = JSON.parse(json); for (const m of masters) { - for (const contour of m.snapshot.contours) { + for (const contour of m.geometry.contours) { expect(contour.points.length).toBeGreaterThan(0); } } @@ -636,9 +641,7 @@ describe("FontEngine Integration - Designspace", () => { const masters = JSON.parse(json); // All masters should have the same contour signature (point counts per contour) - const sigs = masters.map(m => - m.snapshot.contours.map(c => c.points.length).join(",") - ); + const sigs = masters.map((m) => m.geometry.contours.map((c) => c.points.length).join(",")); const uniqueSigs = new Set(sigs); expect(uniqueSigs.size).toBe(1); }); diff --git a/crates/shift-node/src/font_engine.rs b/crates/shift-node/src/font_engine.rs index e3e1e961..48e9237d 100644 --- a/crates/shift-node/src/font_engine.rs +++ b/crates/shift-node/src/font_engine.rs @@ -10,7 +10,7 @@ use shift_core::{ dependency_graph::DependencyGraph, edit_session::EditSession, font_loader::FontLoader, - snapshot::{CommandResult, GlyphSnapshot, MasterSnapshot, RenderContourSnapshot}, + snapshot::{CommandResult, GlyphGeometry, GlyphSnapshot, MasterSnapshot, RenderContourSnapshot}, AnchorId, BooleanOp, ContourId, Font, FontWriter, Glyph, GlyphLayer, GuidelineId, LayerId, Location, NodePositionUpdate, NodeRef, PasteContour, PointId, PointType, UfoWriter, }; @@ -481,10 +481,6 @@ impl FontEngine { })) } - // ═══════════════════════════════════════════════════════════ - // VARIABLE FONT QUERIES - // ═══════════════════════════════════════════════════════════ - #[napi] pub fn is_variable(&self) -> bool { self.font.is_variable() @@ -541,6 +537,7 @@ impl FontEngine { let mut masters: Vec = Vec::new(); + let default_source_id = self.font.default_source_id(); for source in self.font.sources() { let layer_id = source.layer_id(); let layer = match glyph.layer(layer_id) { @@ -575,8 +572,9 @@ impl FontEngine { masters.push(MasterSnapshot { source_id: source.id().raw().to_string(), source_name: source.name().to_string(), + is_default_source: default_source_id == Some(source.id()), location: source.location().clone(), - snapshot, + geometry: GlyphGeometry::from(&snapshot), }); } diff --git a/packages/types/src/generated/GlyphGeometry.ts b/packages/types/src/generated/GlyphGeometry.ts new file mode 100644 index 00000000..2a3f13dd --- /dev/null +++ b/packages/types/src/generated/GlyphGeometry.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AnchorSnapshot } from "./AnchorSnapshot"; +import type { ContourSnapshot } from "./ContourSnapshot"; + +export type GlyphGeometry = { xAdvance: number, contours: Array, anchors: Array, }; From af2df1a18aea0aca1426084069a19b514afd7be0 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 26 Apr 2026 11:41:24 +0100 Subject: [PATCH 41/41] Wire variation: fontdrasil deltas in Rust, eval in TS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per slider tick: Rust ships per-glyph (regions, deltas) once via the new glyph_variation_data NAPI; TS holds them in a ref and runs interpolate(data, normalisedLocation) on every scrub. Zero NAPI on the hot path. Math is a faithful port of fontdrasil's scalar_at_with_args + interpolate_from_deltas. Why: NAPI per glyph per frame doesn't scale to text runs. fontdrasil does the hard work (region construction, delta decomposition) once per glyph; TS does the trivial per-tick eval. Notable changes - shift-core: pure build_master_snapshots(font, glyph); shift-node becomes a thin wrapper that only handles the editing-glyph detour. - Glyph.applyValues(Float64Array): in-place patch, mirrors the drag-path #patchPositions, fires #contours/#anchors via batch(). - Discrete axes (values="0 1", e.g. SLAB) now load with min/max derived from the values list instead of collapsing to default. - Drop the 800-line TS hand-port of fontTools VariationModel. - Exclude generated files + parity fixtures from formatting hooks (community pattern — the generator owns their format). Tests - TS parity vs fontdrasil at the fixture target + at origin. - TS round-trip: interpolate at each master's location recovers that master's flat values within 1e-9 - catches unpacking drift. - 11 Rust axis-range derivation tests (continuous, discrete, one-sided, asymmetric, default-at-boundary). - scalarAt boundary semantics (9 cases). Fixture: packages/types/__fixtures__/variation_parity.json, generated idempotently by crates/shift-core/tests/interpolation_parity.rs from the real MutatorSans designspace. --- .pre-commit-config.yaml | 7 +- .prettierignore | 1 + .../src/renderer/src/bridge/NativeBridge.ts | 13 +- .../renderer/src/components/GlyphPreview.tsx | 30 +- .../src/components/VariationPanel.tsx | 76 +-- .../src/lib/interpolation/interpolate.test.ts | 412 ++++----------- .../src/lib/interpolation/interpolate.ts | 488 ++---------------- .../src/renderer/src/lib/interpolation/svg.ts | 121 ----- .../src/renderer/src/lib/model/Font.ts | 13 +- .../src/renderer/src/lib/model/Glyph.ts | 56 ++ .../shift-backends/src/designspace/reader.rs | 131 ++++- crates/shift-core/src/interpolation.rs | 143 +++-- crates/shift-core/src/snapshot.rs | 3 +- .../shift-core/tests/interpolation_parity.rs | 206 ++++++++ crates/shift-node/index.d.ts | 3 +- crates/shift-node/src/font_engine.rs | 95 +--- .../types/__fixtures__/variation_parity.json | 438 ++++++++++++++++ packages/types/src/font.ts | 6 + packages/types/src/generated/AxisTent.ts | 3 + .../types/src/generated/GlyphVariationData.ts | 13 + .../src/generated/InterpolationResult.ts | 5 + .../types/src/generated/MasterSnapshot.ts | 5 + packages/types/src/generated/SourceError.ts | 3 + packages/types/src/generated/index.ts | 6 + packages/types/src/index.ts | 6 + scripts/patch-generated-types.ts | 71 ++- 26 files changed, 1282 insertions(+), 1072 deletions(-) delete mode 100644 apps/desktop/src/renderer/src/lib/interpolation/svg.ts create mode 100644 crates/shift-core/tests/interpolation_parity.rs create mode 100644 packages/types/__fixtures__/variation_parity.json create mode 100644 packages/types/src/generated/AxisTent.ts create mode 100644 packages/types/src/generated/GlyphVariationData.ts create mode 100644 packages/types/src/generated/InterpolationResult.ts create mode 100644 packages/types/src/generated/MasterSnapshot.ts create mode 100644 packages/types/src/generated/SourceError.ts diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 01866976..c693b690 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,15 @@ repos: # Standard file hygiene + # Generated files (ts-rs output, NAPI .d.ts, parity fixtures) are excluded + # because their format is owned by the generator. Re-formatting them here + # creates a loop with the cargo-test hook that regenerates them. - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: trailing-whitespace + exclude: '^(packages/types/src/generated/|packages/types/__fixtures__/|crates/shift-node/index\.(d\.ts|js))' - id: end-of-file-fixer + exclude: '^(packages/types/src/generated/|packages/types/__fixtures__/|crates/shift-node/index\.(d\.ts|js))' - id: check-yaml - id: check-added-large-files - id: check-merge-conflict @@ -20,7 +25,7 @@ repos: - id: no-console-log name: no console.log in production code - entry: "bash -c 'for f in \"$@\"; do case \"$f\" in *.test.*|*/testing/*|*/e2e/*) continue;; esac; if grep -n \"^\\s*console\\.log\\b\" \"$f\" >/dev/null 2>&1; then echo \"$f has console.log:\"; grep -n \"^\\s*console\\.log\\b\" \"$f\"; exit 1; fi; done'" + entry: "bash -c 'for f in \"$@\"; do case \"$f\" in *.test.*|*/testing/*|*/e2e/*|scripts/*) continue;; esac; if grep -n \"^\\s*console\\.log\\b\" \"$f\" >/dev/null 2>&1; then echo \"$f has console.log:\"; grep -n \"^\\s*console\\.log\\b\" \"$f\"; exit 1; fi; done'" language: system types_or: [javascript, jsx, ts, tsx] diff --git a/.prettierignore b/.prettierignore index 511de71c..a8a5b92b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,6 +6,7 @@ out *.min.css pnpm-lock.yaml packages/types/src/generated +packages/types/__fixtures__ crates/shift-node/index.js crates/shift-node/index.d.ts **/vendor diff --git a/apps/desktop/src/renderer/src/bridge/NativeBridge.ts b/apps/desktop/src/renderer/src/bridge/NativeBridge.ts index afa95f89..d8501215 100644 --- a/apps/desktop/src/renderer/src/bridge/NativeBridge.ts +++ b/apps/desktop/src/renderer/src/bridge/NativeBridge.ts @@ -8,8 +8,9 @@ import type { AnchorId, Axis, Source, + GlyphVariationData, + MasterSnapshot, } from "@shift/types"; -import type { MasterSnapshot } from "@/lib/interpolation/interpolate"; import { signal, type WritableSignal, type Signal } from "@/lib/reactive/signal"; import type { Bounds } from "@shift/geo"; import { Bounds as BoundsUtil } from "@shift/geo"; @@ -165,14 +166,10 @@ export class NativeBridge { return JSON.parse(json) as MasterSnapshot[]; } - /** @knipclassignore — interpolate a glyph at a designspace location in Rust */ - interpolateGlyph( - glyphName: string, - location: Record, - ): InterpolationResult | null { - const json = this.#raw.interpolateGlyph(glyphName, JSON.stringify({ values: location })); + getGlyphVariationData(glyphName: string): GlyphVariationData | null { + const json = this.#raw.getGlyphVariationData(glyphName); if (!json) return null; - return JSON.parse(json) as InterpolationResult; + return JSON.parse(json) as GlyphVariationData; } getSnapshot(): GlyphSnapshot { diff --git a/apps/desktop/src/renderer/src/components/GlyphPreview.tsx b/apps/desktop/src/renderer/src/components/GlyphPreview.tsx index 030794a7..4d1cde69 100644 --- a/apps/desktop/src/renderer/src/components/GlyphPreview.tsx +++ b/apps/desktop/src/renderer/src/components/GlyphPreview.tsx @@ -1,8 +1,6 @@ -import { memo, useMemo } from "react"; +import { memo } from "react"; import type { FontMetrics } from "@shift/types"; import type { Font } from "@/lib/model/Font"; -import { useSignalState } from "@/lib/reactive"; -import { snapshotToSvgPath } from "@/lib/interpolation/svg"; export const CELL_HEIGHT = 75; @@ -62,27 +60,11 @@ export const GlyphPreview = memo(function GlyphPreview({ return null; } - const variationLocation = useSignalState(font.$variationLocation); - - const interpolated = useMemo(() => { - if (!variationLocation || !font.isVariable()) return null; - - const target: Record = {}; - for (const [tag, value] of Object.entries(variationLocation.values)) { - if (value !== undefined) target[tag] = value; - } - - const result = font.interpolateGlyph(name, target); - if (!result) return null; - - return { - path: snapshotToSvgPath(result.instance), - advance: result.instance.xAdvance, - }; - }, [variationLocation, name, font]); - - const svgPath = interpolated?.path ?? font.getSvgPath(name); - const advance = interpolated?.advance ?? font.getAdvance(name); + // TODO Phase D: wire variation interpolation into glyph grid. + // For now grid always shows the default master; the variation slider only + // affects the canvas glyph via VariationPanel. + const svgPath = font.getSvgPath(name); + const advance = font.getAdvance(name); const fontMetrics = font.getMetrics(); const cellWidth = computeCellWidth(fontMetrics, advance, height); const containerStyle = { width: cellWidth, height }; diff --git a/apps/desktop/src/renderer/src/components/VariationPanel.tsx b/apps/desktop/src/renderer/src/components/VariationPanel.tsx index 7fc2c639..2248fca8 100644 --- a/apps/desktop/src/renderer/src/components/VariationPanel.tsx +++ b/apps/desktop/src/renderer/src/components/VariationPanel.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import type { Axis } from "@shift/types"; +import type { Axis, GlyphVariationData, Source } from "@shift/types"; import { SidebarSection } from "./sidebar-right/SidebarSection"; import { getEditor } from "@/store/store"; import { useSignalState } from "@/lib/reactive"; -import { interpolateGlyph, type MasterSnapshot } from "@/lib/interpolation/interpolate"; +import { interpolate, normalize } from "@/lib/interpolation/interpolate"; +import { Input } from "@shift/ui"; export const VariationPanel = () => { const editor = getEditor(); @@ -11,19 +12,22 @@ export const VariationPanel = () => { const fontLoaded = useSignalState(font.$loaded); const [axes, setAxes] = useState([]); + const [sources, setSources] = useState([]); const [location, setLocation] = useState>({}); - const mastersRef = useRef(null); + const variationDataRef = useRef(null); const [isInterpolating, setIsInterpolating] = useState(false); const [editingGlyph, setEditingGlyph] = useState(null); useEffect(() => { if (!fontLoaded || !font.isVariable()) { setAxes([]); + setSources([]); return; } const fontAxes = font.getAxes(); setAxes(fontAxes); + setSources(font.getSources()); const defaults: Record = {}; for (const axis of fontAxes) { @@ -36,55 +40,53 @@ export const VariationPanel = () => { setEditingGlyph(editor.getActiveGlyphName()); }); + // Fetch variation data ONCE per glyph (cached in ref). Slider scrub reads from + // the cache and never goes back to Rust. TODO Phase D: invalidate on glyph + // commit (needs a commit-version signal on Glyph). useEffect(() => { if (axes.length === 0 || !editingGlyph) { - mastersRef.current = null; + variationDataRef.current = null; return; } - - mastersRef.current = font.getGlyphMasterSnapshots(editingGlyph); + variationDataRef.current = font.getGlyphVariationData(editingGlyph); setIsInterpolating(false); }, [axes, editingGlyph, font]); + // Pure JS math, zero NAPI calls. + const applyAt = useCallback( + (newLocation: Record) => { + const data = variationDataRef.current; + if (!data) return; + + const values = interpolate(data, normalize(newLocation, axes)); + const glyph = editor.glyph.peek(); + if (glyph) glyph.applyValues(values); + font.setVariationLocation({ values: newLocation }); + }, + [axes, editor, font], + ); + const handleAxisChange = useCallback( (tag: string, value: number) => { const newLocation = { ...location, [tag]: value }; setLocation(newLocation); - - const ms = mastersRef.current; - if (!ms || ms.length < 2) return; - - const result = interpolateGlyph(ms, axes, newLocation); - if (!result) return; - setIsInterpolating(true); - const glyph = editor.glyph.peek(); - if (glyph) glyph.apply(result.instance); - font.setVariationLocation({ values: newLocation }); + applyAt(newLocation); }, - [location, axes, editor, font], + [location, applyAt], ); const handleMasterClick = useCallback( - (sourceName: string) => { - const masters = mastersRef.current; - if (!masters) return; - - const master = masters.find((m) => m.sourceName === sourceName); - if (!master) return; - + (source: Source) => { const newLocation: Record = {}; for (const axis of axes) { - newLocation[axis.tag] = master.location.values[axis.tag] ?? axis.default; + newLocation[axis.tag] = source.location.values[axis.tag] ?? axis.default; } setLocation(newLocation); - setIsInterpolating(true); - const glyph = editor.glyph.peek(); - if (glyph) glyph.apply(master.snapshot); - font.setVariationLocation({ values: newLocation }); + applyAt(newLocation); }, - [axes, editor, font], + [axes, applyAt], ); const handleResetToSession = useCallback(() => { @@ -106,8 +108,6 @@ export const VariationPanel = () => { if (axes.length === 0) return null; - const masters = mastersRef.current ?? []; - return (
@@ -119,16 +119,16 @@ export const VariationPanel = () => { onChange={(value) => handleAxisChange(axis.tag, value)} /> ))} - {masters.length > 0 && ( + {sources.length > 0 && (
- {masters.map((m) => ( + {sources.map((s) => ( ))}
@@ -162,7 +162,7 @@ const AxisSlider = ({ axis, value, onChange }: AxisSliderProps) => { {axis.name} {displayValue}
- ): Axis { - return { - tag: "wght", - name: "Weight", - minimum: 0, - default: 0, - maximum: 1000, - hidden: false, - ...overrides, - }; -} - -function makeContour(points: Array<{ x: number; y: number }>): ContourSnapshot { - return { - id: "c1" as never, - closed: true, - points: points.map((p, i) => ({ - id: `p${i}` as never, - x: p.x, - y: p.y, - pointType: "onCurve" as never, - smooth: false, - })), - }; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { GlyphVariationData } from "@shift/types"; +import { interpolate, scalarAt, type NormalizedLocation } from "./interpolate"; + +interface MasterEntry { + sourceName: string; + isDefaultSource: boolean; + designspaceLocation: Record; + normalisedLocation: NormalizedLocation; + expected: number[]; } -function makeSnapshot(contours: ContourSnapshot[], xAdvance: number): GlyphSnapshot { - return { - unicode: 65, - name: "A", - xAdvance, - contours, - anchors: [], - compositeContours: [], - activeContourId: null, - }; +interface Fixture { + glyphName: string; + designspaceTarget: Record; + normalisedLocation: NormalizedLocation; + data: GlyphVariationData; + expected: number[]; + masters: MasterEntry[]; } -function makeMaster( - name: string, - locationValues: Record, - contour: ContourSnapshot, - xAdvance: number, -): MasterSnapshot { - return { - sourceId: name, - sourceName: name, - location: { values: locationValues }, - snapshot: makeSnapshot([contour], xAdvance), - }; +function loadFixture(): Fixture { + // Generated by `cargo test -p shift-core --test interpolation_parity`. + // vitest runs with cwd at apps/desktop; repo root is two up. + const path = resolve(process.cwd(), "../../packages/types/__fixtures__/variation_parity.json"); + return JSON.parse(readFileSync(path, "utf-8")) as Fixture; } -/** Unwrap a non-null InterpolationResult */ -function unwrap(result: InterpolationResult | null): InterpolationResult { - expect(result).not.toBeNull(); - return result!; -} - -describe("normalizeAxisValue", () => { - const axis = makeAxis({ minimum: 100, default: 400, maximum: 900 }); - - it("returns 0 at default", () => { - expect(normalizeAxisValue(400, axis)).toBe(0); - }); - - it("returns -1 at minimum", () => { - expect(normalizeAxisValue(100, axis)).toBeCloseTo(-1); - }); - - it("returns 1 at maximum", () => { - expect(normalizeAxisValue(900, axis)).toBeCloseTo(1); - }); - - it("returns -0.5 at midpoint below default", () => { - expect(normalizeAxisValue(250, axis)).toBeCloseTo(-0.5); +describe("interpolate — parity with fontdrasil", () => { + it("matches Rust interpolate_from_deltas at the fixture target", () => { + const fx = loadFixture(); + const result = interpolate(fx.data, fx.normalisedLocation); + + expect(result.length).toBe(fx.expected.length); + const tolerance = 1e-9; + for (let i = 0; i < result.length; i++) { + const diff = Math.abs(result[i] - fx.expected[i]); + expect( + diff, + `index ${i}: got ${result[i]}, expected ${fx.expected[i]}, diff ${diff}`, + ).toBeLessThan(tolerance); + } + }); + + it("returns the default master values at the origin", () => { + const fx = loadFixture(); + const origin: NormalizedLocation = {}; + for (const tag of Object.keys(fx.normalisedLocation)) origin[tag] = 0; + + const result = interpolate(fx.data, origin); + // Default master = first deltas entry (its region has all-zero tents → scalar 1 + // at origin and elsewhere; other regions have non-zero scalar only off-origin). + // At origin, only the default's contribution should remain. + expect(result.length).toBe(fx.data.deltas[0].length); + for (let i = 0; i < result.length; i++) { + expect(Math.abs(result[i] - fx.data.deltas[0][i])).toBeLessThan(1e-9); + } }); }); -describe("checkCompatibility", () => { - it("returns null for compatible masters", () => { - const light = makeMaster( - "Light", - { wght: 0 }, - makeContour([ - { x: 0, y: 0 }, - { x: 100, y: 0 }, - ]), - 500, - ); - const bold = makeMaster( - "Bold", - { wght: 1000 }, - makeContour([ - { x: 10, y: 0 }, - { x: 110, y: 0 }, - ]), - 600, - ); - expect(checkCompatibility([light, bold])).toBeNull(); - }); - - it("reports contour count mismatch", () => { - const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 0, y: 0 }]), 500); - const bold: MasterSnapshot = { - ...makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 0, y: 0 }]), 600), - snapshot: makeSnapshot([makeContour([{ x: 0, y: 0 }]), makeContour([{ x: 50, y: 50 }])], 600), - }; - expect(checkCompatibility([light, bold])).toContain("2 contours"); - }); - - it("reports point count mismatch", () => { - const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 0, y: 0 }]), 500); - const bold = makeMaster( - "Bold", - { wght: 1000 }, - makeContour([ - { x: 0, y: 0 }, - { x: 100, y: 0 }, - ]), - 600, - ); - expect(checkCompatibility([light, bold])).toContain("points"); +describe("interpolate — round-trip at master locations", () => { + // Strongest defence against unpacking drift: at each master's location, the + // interpolated values must match that master's stored flat geometry. This + // exercises every region's tent in turn (each master sits at exactly one + // off-default region's peak, plus the default region with scalar=1). + it("recovers each master's flat values when interpolating at its own location", () => { + const fx = loadFixture(); + expect(fx.masters.length).toBeGreaterThan(0); + + for (const master of fx.masters) { + const result = interpolate(fx.data, master.normalisedLocation); + + expect(result.length, `${master.sourceName}: length`).toBe(master.expected.length); + for (let i = 0; i < result.length; i++) { + const diff = Math.abs(result[i] - master.expected[i]); + expect( + diff, + `${master.sourceName} index ${i}: got ${result[i]}, expected ${master.expected[i]}, diff ${diff}`, + ).toBeLessThan(1e-9); + } + } + }); + + it("identifies exactly one default master in the fixture", () => { + // Sanity check on the fixture itself — fontdrasil's invariant. + const fx = loadFixture(); + const defaults = fx.masters.filter((m) => m.isDefaultSource); + expect(defaults.length).toBe(1); }); }); -describe("interpolateGlyph", () => { - const axes = [makeAxis()]; - - it("returns the single master's snapshot when only one master", () => { - const master = makeMaster("Regular", { wght: 0 }, makeContour([{ x: 100, y: 200 }]), 500); - const result = unwrap(interpolateGlyph([master], axes, { wght: 0 })); - expect(result.instance).toBe(master.snapshot); - expect(result.errors).toHaveLength(0); +describe("scalarAt — boundary semantics", () => { + it("returns 1 at the peak", () => { + expect(scalarAt({ wght: 0.5 }, [{ axisTag: "wght", lower: 0, peak: 0.5, upper: 1 }])).toBe(1); }); - it("interpolates at midpoint between two masters", () => { - const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 100, y: 200 }]), 500); - const bold = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 200, y: 400 }]), 700); - - const { instance } = unwrap(interpolateGlyph([light, bold], axes, { wght: 500 })); - - expect(instance.contours[0].points[0].x).toBeCloseTo(150); - expect(instance.contours[0].points[0].y).toBeCloseTo(300); - expect(instance.xAdvance).toBeCloseTo(600); + it("returns 0 at the lower boundary (inclusive)", () => { + expect(scalarAt({ wght: 0 }, [{ axisTag: "wght", lower: 0, peak: 0.5, upper: 1 }])).toBe(0); }); - it("returns default master at default location", () => { - const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 100, y: 200 }]), 500); - const bold = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 200, y: 400 }]), 700); - - const { instance } = unwrap(interpolateGlyph([light, bold], axes, { wght: 0 })); - - expect(instance.contours[0].points[0].x).toBeCloseTo(100); - expect(instance.contours[0].points[0].y).toBeCloseTo(200); + it("returns 0 at the upper boundary (inclusive)", () => { + expect(scalarAt({ wght: 1 }, [{ axisTag: "wght", lower: 0, peak: 0.5, upper: 1 }])).toBe(0); }); - it("returns non-default master at its location", () => { - const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 100, y: 200 }]), 500); - const bold = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 200, y: 400 }]), 700); - - const { instance } = unwrap(interpolateGlyph([light, bold], axes, { wght: 1000 })); - - expect(instance.contours[0].points[0].x).toBeCloseTo(200); - expect(instance.contours[0].points[0].y).toBeCloseTo(400); + it("returns 0 outside the tent", () => { + expect(scalarAt({ wght: 1.5 }, [{ axisTag: "wght", lower: 0, peak: 0.5, upper: 1 }])).toBe(0); + expect(scalarAt({ wght: -0.5 }, [{ axisTag: "wght", lower: 0, peak: 0.5, upper: 1 }])).toBe(0); }); - it("preserves point metadata from reference master", () => { - const light = makeMaster("Light", { wght: 0 }, makeContour([{ x: 100, y: 200 }]), 500); - const bold = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 200, y: 400 }]), 700); - - const { instance } = unwrap(interpolateGlyph([light, bold], axes, { wght: 500 })); - - expect(instance.contours[0].points[0].id).toBe(light.snapshot.contours[0].points[0].id); - expect(instance.contours[0].points[0].pointType).toBe("onCurve"); - expect(instance.contours[0].id).toBe(light.snapshot.contours[0].id); - }); - - it("interpolates with 4 masters on 2 axes", () => { - const wdthAxis = makeAxis({ tag: "wdth", name: "Width" }); - const wghtAxis = makeAxis({ tag: "wght", name: "Weight" }); - - const lc = makeMaster( - "LightCondensed", - { wdth: 0, wght: 0 }, - makeContour([{ x: 0, y: 0 }]), - 400, - ); - const bc = makeMaster( - "BoldCondensed", - { wdth: 0, wght: 1000 }, - makeContour([{ x: 100, y: 0 }]), - 500, - ); - const lw = makeMaster( - "LightWide", - { wdth: 1000, wght: 0 }, - makeContour([{ x: 0, y: 100 }]), - 600, - ); - const bw = makeMaster( - "BoldWide", - { wdth: 1000, wght: 1000 }, - makeContour([{ x: 100, y: 100 }]), - 700, - ); - - const { instance, errors } = unwrap( - interpolateGlyph([lc, bc, lw, bw], [wdthAxis, wghtAxis], { wdth: 500, wght: 500 }), - ); - - expect(errors).toHaveLength(0); - expect(instance.contours[0].points[0].x).toBeCloseTo(50); - expect(instance.contours[0].points[0].y).toBeCloseTo(50); - expect(instance.xAdvance).toBeCloseTo(550); - }); - - it("deduplicates masters at the same normalized location", () => { - const m1 = makeMaster("Sans", { wght: 0 }, makeContour([{ x: 100, y: 200 }]), 500); - const m2 = makeMaster("Slab", { wght: 0 }, makeContour([{ x: 100, y: 200 }]), 500); - const m3 = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 200, y: 400 }]), 700); - - const { instance } = unwrap(interpolateGlyph([m1, m2, m3], axes, { wght: 500 })); - - expect(instance.contours[0].points[0].x).toBeCloseTo(150); + it("ramps linearly from lower to peak", () => { + // halfway between lower=0 and peak=0.5 → scalar 0.5 + expect( + scalarAt({ wght: 0.25 }, [{ axisTag: "wght", lower: 0, peak: 0.5, upper: 1 }]), + ).toBeCloseTo(0.5, 9); }); -}); - -describe("interpolateGlyph — incompatible sources", () => { - const axes = [makeAxis()]; - - it("reports incompatible source and still produces a result", () => { - const defaultMaster = makeMaster("Default", { wght: 0 }, makeContour([{ x: 0, y: 0 }]), 500); - const compatible = makeMaster("Bold", { wght: 1000 }, makeContour([{ x: 200, y: 0 }]), 700); - const incompatible: MasterSnapshot = { - sourceId: "Bad", - sourceName: "Bad", - location: { values: { wght: 500 } }, - snapshot: makeSnapshot( - [makeContour([{ x: 100, y: 0 }]), makeContour([{ x: 50, y: 50 }])], - 600, - ), - }; - const { instance, errors } = unwrap( - interpolateGlyph([defaultMaster, incompatible, compatible], axes, { wght: 1000 }), - ); - - // Incompatible source reported in errors - expect(errors.length).toBeGreaterThan(0); - expect(errors[0].sourceName).toBe("Bad"); - - // At wght=1000 (bold's exact location), result should match bold - expect(instance.contours[0].points[0].x).toBeCloseTo(200); + it("ramps linearly from peak to upper", () => { + // halfway between peak=0.5 and upper=1 → scalar 0.5 + expect( + scalarAt({ wght: 0.75 }, [{ axisTag: "wght", lower: 0, peak: 0.5, upper: 1 }]), + ).toBeCloseTo(0.5, 9); }); - it("returns default when only one compatible source", () => { - const defaultMaster = makeMaster("Default", { wght: 0 }, makeContour([{ x: 0, y: 0 }]), 500); - const incompat: MasterSnapshot = { - sourceId: "Bad", - sourceName: "Bad", - location: { values: { wght: 1000 } }, - snapshot: makeSnapshot([makeContour([{ x: 0, y: 0 }]), makeContour([{ x: 50, y: 50 }])], 700), - }; - - const { instance, errors } = unwrap( - interpolateGlyph([defaultMaster, incompat], axes, { wght: 500 }), - ); - - // Incompatible source zeroed out — result is just the default - expect(errors.length).toBeGreaterThan(0); - expect(instance.contours[0].points[0].x).toBeCloseTo(0); + it("treats (0,0,0) tent as always-on", () => { + // Default-region tents — full influence regardless of location + expect(scalarAt({ wght: 0.7 }, [{ axisTag: "wght", lower: 0, peak: 0, upper: 0 }])).toBe(1); }); - it("handles all sources incompatible except default", () => { - const defaultMaster = makeMaster("Default", { wght: 0 }, makeContour([{ x: 10, y: 20 }]), 500); - const bad1: MasterSnapshot = { - sourceId: "Bad1", - sourceName: "Bad1", - location: { values: { wght: 500 } }, - snapshot: makeSnapshot([makeContour([{ x: 0, y: 0 }]), makeContour([{ x: 50, y: 50 }])], 600), - }; - const bad2: MasterSnapshot = { - sourceId: "Bad2", - sourceName: "Bad2", - location: { values: { wght: 1000 } }, - snapshot: makeSnapshot( - [ - makeContour([{ x: 0, y: 0 }]), - makeContour([{ x: 50, y: 50 }]), - makeContour([{ x: 99, y: 99 }]), - ], - 700, - ), - }; - - const { instance, errors } = unwrap( - interpolateGlyph([defaultMaster, bad1, bad2], axes, { wght: 500 }), - ); - - expect(errors).toHaveLength(2); - // Result is the default since both other deltas are zeroed - expect(instance.contours[0].points[0].x).toBeCloseTo(10); - expect(instance.contours[0].points[0].y).toBeCloseTo(20); + it("multiplies scalars across multiple axes", () => { + // Each axis halfway up its ramp → 0.5 × 0.5 = 0.25 + const region = [ + { axisTag: "wght", lower: 0, peak: 0.5, upper: 1 }, + { axisTag: "wdth", lower: 0, peak: 0.5, upper: 1 }, + ]; + expect(scalarAt({ wght: 0.25, wdth: 0.25 }, region)).toBeCloseTo(0.25, 9); }); - it("errors include the source name and a message", () => { - const defaultMaster = makeMaster("Default", { wght: 0 }, makeContour([{ x: 0, y: 0 }]), 500); - const incompat: MasterSnapshot = { - sourceId: "Wonky", - sourceName: "Wonky", - location: { values: { wght: 1000 } }, - snapshot: makeSnapshot([makeContour([{ x: 0, y: 0 }]), makeContour([{ x: 50, y: 50 }])], 700), - }; - - const { errors } = unwrap(interpolateGlyph([defaultMaster, incompat], axes, { wght: 500 })); - - expect(errors).toHaveLength(1); - expect(errors[0].sourceName).toBe("Wonky"); - expect(errors[0].message).toContain("contour count"); + it("returns 0 for invalid tents (peak < lower)", () => { + expect(scalarAt({ wght: 0.5 }, [{ axisTag: "wght", lower: 0.5, peak: 0, upper: 1 }])).toBe(1); // invalid tent skipped, no other tents → scalar stays 1 }); }); diff --git a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts index bd58d4fe..fac5dd33 100644 --- a/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts +++ b/apps/desktop/src/renderer/src/lib/interpolation/interpolate.ts @@ -1,471 +1,91 @@ /** - * Glyph interpolation engine using the OpenType VariationModel algorithm. + * Variable-font interpolation — per-tick eval path. * - * Ported from Fontra's var-model.js which is itself a port of - * fontTools.varLib.models.VariationModel. Uses support-region box-splitting - * and delta decomposition for correct multilinear interpolation. + * The hard math (region construction + delta decomposition) lives in Rust via + * `fontdrasil`. This module is the trivial per-tick eval — region tent function + * and a dot product against cached deltas. Ported faithfully from fontdrasil's + * `scalar_at_with_args` and `interpolate_from_deltas`. * - * Incompatible sources are handled per-source during delta computation - * (matching Fontra's approach): if subItemwise throws for a source, that - * source's delta is zeroed out rather than failing the whole interpolation. + * Parity-tested against `packages/types/__fixtures__/variation_parity.json`, + * generated by `crates/shift-core/tests/interpolation_parity.rs`. */ -import type { Axis, GlyphSnapshot, ContourSnapshot, PointSnapshot } from "@shift/types"; -/** A single master's glyph data with its design-space location. */ -export interface MasterSnapshot { - sourceId: string; - sourceName: string; - location: { values: { [key in string]?: number } }; - snapshot: GlyphSnapshot; -} +import type { Axis, AxisTent, GlyphVariationData } from "@shift/types"; -/** Per-source error from delta computation. */ -export interface SourceError { - sourceIndex: number; - sourceName: string; - message: string; -} +export type NormalizedLocation = Record; +export type DesignspaceLocation = Record; -export function normalizeAxisValue(value: number, axis: Axis): number { +/** Asymmetric normalize — mirrors `Axis::normalize` in shift-ir/src/axis.rs. */ +export function normalizeAxis(value: number, axis: Axis): number { if (value < axis.default) { const range = axis.default - axis.minimum; - return range < Number.EPSILON ? 0 : (value - axis.default) / range; + return range === 0 ? 0 : (value - axis.default) / range; } - if (value > axis.default) { const range = axis.maximum - axis.default; - return range < Number.EPSILON ? 0 : (value - axis.default) / range; + return range === 0 ? 0 : (value - axis.default) / range; } - return 0; } -type SparseLocation = Record; -type Support = Record; - -function normalizeLocation( - location: Record, - axes: Axis[], -): SparseLocation { - const out: SparseLocation = {}; +export function normalize(loc: DesignspaceLocation, axes: Axis[]): NormalizedLocation { + const out: NormalizedLocation = {}; for (const axis of axes) { - const v = location[axis.tag] ?? axis.default; - const n = normalizeAxisValue(v, axis); - if (Math.abs(n) > 1e-14) { - out[axis.tag] = n; - } + out[axis.tag] = normalizeAxis(loc[axis.tag] ?? axis.default, axis); } return out; } -export function checkCompatibility(masters: MasterSnapshot[]): string | null { - if (masters.length < 2) return null; - - const ref = masters[0].snapshot; - - for (let m = 1; m < masters.length; m++) { - const other = masters[m].snapshot; - - if (ref.contours.length !== other.contours.length) { - return `Master "${masters[m].sourceName}" has ${other.contours.length} contours, expected ${ref.contours.length}`; - } - - for (let c = 0; c < ref.contours.length; c++) { - if (ref.contours[c].points.length !== other.contours[c].points.length) { - return `Master "${masters[m].sourceName}" contour ${c}: ${other.contours[c].points.length} points, expected ${ref.contours[c].points.length}`; - } - } - } - - return null; -} - -// -// These operate on the structured glyph data directly. If two snapshots -// have different contour/point counts, the operation throws — callers -// catch per-source to handle incompatibility gracefully. - -class IncompatibleError extends Error { - constructor(message: string) { - super(message); - this.name = "IncompatibleError"; - } -} - -function subPoints(a: PointSnapshot[], b: PointSnapshot[]): PointSnapshot[] { - if (a.length !== b.length) { - throw new IncompatibleError(`point count ${a.length} vs ${b.length}`); - } - return a.map((ap, i) => ({ ...ap, x: ap.x - b[i].x, y: ap.y - b[i].y })); -} - -function addPoints(a: PointSnapshot[], b: PointSnapshot[]): PointSnapshot[] { - if (a.length !== b.length) { - throw new IncompatibleError(`point count ${a.length} vs ${b.length}`); - } - return a.map((ap, i) => ({ ...ap, x: ap.x + b[i].x, y: ap.y + b[i].y })); -} - -function mulScalarPoints(pts: PointSnapshot[], s: number): PointSnapshot[] { - return pts.map((p) => ({ ...p, x: p.x * s, y: p.y * s })); -} - -function subContours(a: ContourSnapshot[], b: ContourSnapshot[]): ContourSnapshot[] { - if (a.length !== b.length) { - throw new IncompatibleError(`contour count ${a.length} vs ${b.length}`); - } - return a.map((ac, i) => ({ - ...ac, - points: subPoints(ac.points, b[i].points), - })); -} - -function addContours(a: ContourSnapshot[], b: ContourSnapshot[]): ContourSnapshot[] { - if (a.length !== b.length) { - throw new IncompatibleError(`contour count ${a.length} vs ${b.length}`); - } - return a.map((ac, i) => ({ - ...ac, - points: addPoints(ac.points, b[i].points), - })); -} - -function mulScalarContours(contours: ContourSnapshot[], s: number): ContourSnapshot[] { - return contours.map((c) => ({ ...c, points: mulScalarPoints(c.points, s) })); -} - -/** Subtract snapshot B from A: A - B */ -function subSnapshot(a: GlyphSnapshot, b: GlyphSnapshot): GlyphSnapshot { - return { - ...a, - xAdvance: a.xAdvance - b.xAdvance, - contours: subContours(a.contours, b.contours), - }; -} - -/** Add snapshot B to A: A + B */ -function addSnapshot(a: GlyphSnapshot, b: GlyphSnapshot): GlyphSnapshot { - return { - ...a, - xAdvance: a.xAdvance + b.xAdvance, - contours: addContours(a.contours, b.contours), - }; -} - -/** Multiply all coordinates in a snapshot by a scalar */ -function mulScalarSnapshot(snap: GlyphSnapshot, s: number): GlyphSnapshot { - if (s === 1) return snap; - return { - ...snap, - xAdvance: snap.xAdvance * s, - contours: mulScalarContours(snap.contours, s), - }; -} - -/** A zero-valued snapshot with the same structure as the reference. */ -function zeroSnapshot(ref: GlyphSnapshot): GlyphSnapshot { - return mulScalarSnapshot(ref, 0); -} - -function supportScalar(location: SparseLocation, support: Support): number { - let scalar = 1.0; - for (const axis in support) { - const [lower, peak, upper] = support[axis]; - if (peak === 0.0) continue; - if (lower > peak || peak > upper) continue; - if (lower < 0.0 && upper > 0.0) continue; - - const v = location[axis] ?? 0.0; - if (v === peak) continue; - if (v <= lower || upper <= v) return 0.0; - if (v < peak) { - scalar *= (v - lower) / (peak - lower); - } else { - scalar *= (v - upper) / (peak - upper); - } - } - return scalar; -} - -function locationsToRegions(locations: SparseLocation[]): Support[] { - const minV: Record = {}; - const maxV: Record = {}; - for (const loc of locations) { - for (const [k, v] of Object.entries(loc)) { - minV[k] = Math.min(v, minV[k] ?? v); - maxV[k] = Math.max(v, maxV[k] ?? v); - } - } - - return locations.map((loc) => { - const region: Support = {}; - for (const [axis, locV] of Object.entries(loc)) { - region[axis] = locV > 0 ? [0, locV, maxV[axis]] : [minV[axis], locV, 0]; - } - return region; - }); -} - -function locationToString(loc: SparseLocation): string { - const sorted: SparseLocation = {}; - for (const key of Object.keys(loc).sort()) { - sorted[key] = loc[key]; - } - return JSON.stringify(sorted); -} - -function isSuperset(set: Set, keys: string[]): boolean { - for (const k of keys) { - if (!set.has(k)) return false; - } +/** Mirrors fontdrasil's `Tent::validate` — used as a per-tent filter. */ +function isValidTent(t: AxisTent): boolean { + if (t.lower > t.peak || t.peak > t.upper) return false; + if (t.lower < 0 && t.upper > 0) return false; // can't span across default return true; } -function deepCompare(a: unknown[], b: unknown[]): number { - const length = Math.max(a.length, b.length); - for (let i = 0; i < length; i++) { - const itemA = a[i]; - const itemB = b[i]; - if (itemA === undefined) return -1; - if (itemB === undefined) return 1; - if (Array.isArray(itemA) && Array.isArray(itemB)) { - const r = deepCompare(itemA, itemB); - if (r !== 0) return r; - } else if (typeof itemA === "number" && typeof itemB === "number") { - if (itemA < itemB) return -1; - if (itemA > itemB) return 1; - } else if (typeof itemA === "string" && typeof itemB === "string") { - if (itemA < itemB) return -1; - if (itemA > itemB) return 1; - } - } - return 0; -} - -interface VariationModelData { - mapping: number[]; - reverseMapping: number[]; - supports: Support[]; - deltaWeights: Map[]; -} - -function buildVariationModel( - masterLocations: SparseLocation[], - axisOrder: string[], -): VariationModelData { - const axisPoints: Record> = {}; - for (const loc of masterLocations) { - const keys = Object.keys(loc); - if (keys.length !== 1) continue; - const axis = keys[0]; - if (!axisPoints[axis]) axisPoints[axis] = new Set([0.0]); - axisPoints[axis].add(loc[axis]); - } - - const decorated: [unknown[], SparseLocation, number][] = masterLocations.map((loc, i) => { - const entries = Object.entries(loc); - const rank = entries.length; - const onPointAxes: string[] = []; - for (const [axis, value] of entries) { - if (axisPoints[axis]?.has(value)) onPointAxes.push(axis); - } - const orderedAxes = [ - ...axisOrder.filter((a) => loc[a] !== undefined), - ...Object.keys(loc) - .sort() - .filter((a) => !axisOrder.includes(a)), - ]; - const deco: unknown[] = [ - rank, - -onPointAxes.length, - orderedAxes.map((a) => { - const idx = axisOrder.indexOf(a); - return idx !== -1 ? idx : 0x10000; - }), - orderedAxes, - orderedAxes.map((a) => Math.sign(loc[a])), - orderedAxes.map((a) => Math.abs(loc[a])), - ]; - return [deco, loc, i]; - }); - - decorated.sort((a, b) => deepCompare(a[0] as unknown[], b[0] as unknown[])); - const sortedLocations = decorated.map((d) => d[1]); +/** + * Scalar contribution of a region at a normalized location. + * Exact port of fontdrasil's `VariationRegion::scalar_at_with_args` (no extrapolation). + */ +export function scalarAt(loc: NormalizedLocation, region: AxisTent[]): number { + let scalar = 1; + for (const t of region) { + if (!isValidTent(t)) continue; - const locStrings = masterLocations.map(locationToString); - const sortedStrings = sortedLocations.map(locationToString); - const mapping = locStrings.map((s) => sortedStrings.indexOf(s)); - const reverseMapping = sortedStrings.map((s) => locStrings.indexOf(s)); + const v = loc[t.axisTag] ?? 0; - // Compute supports via box-splitting - const regions = locationsToRegions(sortedLocations); - const supports: Support[] = []; + if (v === t.peak) continue; - for (let i = 0; i < regions.length; i++) { - const region = { ...regions[i] }; - for (const axis in region) { - region[axis] = [...region[axis]]; + if (t.lower === 0 && t.peak === 0 && t.upper === 0) { + continue; // (0,0,0) tent = always-on (default region) } - const locAxes = new Set(Object.keys(region)); - - for (let j = 0; j < i; j++) { - const prevRegion = supports[j]; - if (!isSuperset(locAxes, Object.keys(prevRegion))) continue; - - let relevant = true; - for (const [axis, [lower, , upper]] of Object.entries(region)) { - const prev = prevRegion[axis]; - if (!prev || !(prev[1] === region[axis][1] || (lower < prev[1] && prev[1] < upper))) { - relevant = false; - break; - } - } - if (!relevant) continue; - let bestAxes: Record = {}; - let bestRatio = -1; - for (const axis of Object.keys(prevRegion)) { - const val = prevRegion[axis][1]; - const [lower, locV, upper] = region[axis]; - let ratio: number; - if (val < locV) { - ratio = (val - locV) / (lower - locV); - if (ratio > bestRatio) { - bestAxes = {}; - bestRatio = ratio; - } - if (ratio === bestRatio) bestAxes[axis] = [val, locV, upper]; - } else if (locV < val) { - ratio = (val - locV) / (upper - locV); - if (ratio > bestRatio) { - bestAxes = {}; - bestRatio = ratio; - } - if (ratio === bestRatio) bestAxes[axis] = [lower, locV, val]; - } - } - for (const axis in bestAxes) { - region[axis] = bestAxes[axis]; - } - } - supports.push(region); - } + if (v <= t.lower || t.upper <= v) return 0; // outside, boundary inclusive - const deltaWeights: Map[] = []; - for (let i = 0; i < sortedLocations.length; i++) { - const loc = sortedLocations[i]; - const dw = new Map(); - for (let j = 0; j < i; j++) { - const scalar = supportScalar(loc, supports[j]); - if (scalar) dw.set(j, scalar); - } - deltaWeights.push(dw); + const edge = v < t.peak ? t.lower : t.upper; + scalar *= (v - edge) / (t.peak - edge); } - - return { mapping, reverseMapping, supports, deltaWeights }; -} - -export interface InterpolationResult { - instance: GlyphSnapshot; - /** Sources that were incompatible and excluded from interpolation. */ - errors: SourceError[]; + return scalar; } /** - * Interpolate a glyph at a target design-space location using the - * OpenType VariationModel algorithm (support regions + delta decomposition). + * Evaluate per-region deltas at a normalized location. + * Exact port of fontdrasil's `VariationModel::interpolate_from_deltas` (no extrapolation). * - * Follows Fontra's approach: build the model with ALL sources, then handle - * incompatibility per-source during delta computation. Incompatible sources - * get a zero delta (no contribution) and are reported in `errors`. + * Output values are absolute (not offsets from default) because the default + * master's region has empty/zero support and contributes its full delta at scalar=1. + * + * Order is the `flatten()` shape from shift-core::interpolation: + * [xAdvance, p0.x, p0.y, p1.x, p1.y, ..., a0.x, a0.y, ...] */ -export function interpolateGlyph( - masters: MasterSnapshot[], - axes: Axis[], - target: Record, -): InterpolationResult | null { - if (masters.length === 0) return null; - if (masters.length === 1) return { instance: masters[0].snapshot, errors: [] }; - - // Normalize master locations (sparse: omit axes at default) - const normalizedLocations = masters.map((m) => normalizeLocation(m.location.values, axes)); - - // Deduplicate locations — keep only the first master at each location - const seen = new Set(); - const uniqueIndices: number[] = []; - for (let i = 0; i < normalizedLocations.length; i++) { - const key = locationToString(normalizedLocations[i]); - if (!seen.has(key)) { - seen.add(key); - uniqueIndices.push(i); - } - } - const uniqueMasters = uniqueIndices.map((i) => masters[i]); - const uniqueLocations = uniqueIndices.map((i) => normalizedLocations[i]); - - if (uniqueMasters.length < 2) { - return { instance: uniqueMasters[0]?.snapshot ?? masters[0].snapshot, errors: [] }; - } - - // The VariationModel requires a default location ({}) - const hasDefault = uniqueLocations.some((loc) => Object.keys(loc).length === 0); - if (!hasDefault) return null; - - const axisOrder = axes.map((a) => a.tag); - - let model: VariationModelData; - try { - model = buildVariationModel(uniqueLocations, axisOrder); - } catch { - return null; - } - - // Find the default master's snapshot (for zero-value reference) - const defaultIdx = uniqueLocations.findIndex((loc) => Object.keys(loc).length === 0); - const defaultSnapshot = uniqueMasters[defaultIdx].snapshot; - - // Compute deltas with per-source error handling. - // If subSnapshot throws for a source, that source gets a zero delta. - const errors: SourceError[] = []; - const deltas: GlyphSnapshot[] = []; - - for (let i = 0; i < uniqueMasters.length; i++) { - const masterValue = uniqueMasters[model.reverseMapping[i]].snapshot; - - try { - let delta = masterValue; - const weights = model.deltaWeights[i]; - for (const [j, weight] of weights.entries()) { - const prev = weight === 1 ? deltas[j] : mulScalarSnapshot(deltas[j], weight); - delta = subSnapshot(delta, prev); - } - deltas.push(delta); - } catch (e) { - // This source is incompatible — zero delta, no contribution. - const originalIdx = model.reverseMapping[i]; - errors.push({ - sourceIndex: originalIdx, - sourceName: uniqueMasters[originalIdx].sourceName, - message: e instanceof Error ? e.message : String(e), - }); - deltas.push(zeroSnapshot(defaultSnapshot)); - } - } - - // Compute scalars at target location - const normalizedTarget = normalizeLocation(target, axes); - const scalars = model.supports.map((support) => supportScalar(normalizedTarget, support)); - - // Interpolate: sum delta[i] * scalar[i] - let result: GlyphSnapshot | null = null; - for (let i = 0; i < scalars.length; i++) { - if (!scalars[i]) continue; - const contribution = mulScalarSnapshot(deltas[i], scalars[i]); - result = result === null ? contribution : addSnapshot(result, contribution); - } - - return { - instance: result ?? defaultSnapshot, - errors, - }; +export function interpolate(data: GlyphVariationData, loc: NormalizedLocation): Float64Array { + const len = data.deltas[0]?.length ?? 0; + const result = new Float64Array(len); + for (let r = 0; r < data.regions.length; r++) { + const s = scalarAt(loc, data.regions[r]); + if (s === 0) continue; + const d = data.deltas[r]; + for (let i = 0; i < len; i++) result[i] += s * d[i]; + } + return result; } diff --git a/apps/desktop/src/renderer/src/lib/interpolation/svg.ts b/apps/desktop/src/renderer/src/lib/interpolation/svg.ts deleted file mode 100644 index a09ade41..00000000 --- a/apps/desktop/src/renderer/src/lib/interpolation/svg.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { GlyphSnapshot, PointSnapshot } from "@shift/types"; -import { Validate } from "@shift/validation"; - -/** - * Convert a GlyphSnapshot's contours into an SVG path `d` attribute string. - * Handles line, quadratic, and cubic segments based on point types. - */ -export function snapshotToSvgPath(snapshot: GlyphSnapshot): string { - const parts: string[] = []; - - for (const contour of snapshot.contours) { - const d = contourToSvgD(contour.points, contour.closed); - if (d) parts.push(d); - } - - return parts.join(" "); -} - -function contourToSvgD(points: readonly PointSnapshot[], closed: boolean): string { - if (points.length < 2) return ""; - - const segments = buildSegments(points, closed); - const cmds: string[] = []; - let first = true; - - for (const seg of segments) { - switch (seg.type) { - case "line": { - if (first) { - cmds.push(`M ${fmt(seg.p1.x)} ${fmt(seg.p1.y)}`); - first = false; - } - cmds.push(`L ${fmt(seg.p2.x)} ${fmt(seg.p2.y)}`); - break; - } - case "quad": { - if (first) { - cmds.push(`M ${fmt(seg.p1.x)} ${fmt(seg.p1.y)}`); - first = false; - } - cmds.push(`Q ${fmt(seg.cp.x)} ${fmt(seg.cp.y)} ${fmt(seg.p2.x)} ${fmt(seg.p2.y)}`); - break; - } - case "cubic": { - if (first) { - cmds.push(`M ${fmt(seg.p1.x)} ${fmt(seg.p1.y)}`); - first = false; - } - cmds.push( - `C ${fmt(seg.cp1.x)} ${fmt(seg.cp1.y)} ${fmt(seg.cp2.x)} ${fmt(seg.cp2.y)} ${fmt(seg.p2.x)} ${fmt(seg.p2.y)}`, - ); - break; - } - } - } - - if (closed && cmds.length > 0) cmds.push("Z"); - - return cmds.join(" "); -} - -function fmt(n: number): string { - return Math.round(n * 100) / 100 + ""; -} - -type Coord = { x: number; y: number }; -type Segment = - | { type: "line"; p1: Coord; p2: Coord } - | { type: "quad"; p1: Coord; cp: Coord; p2: Coord } - | { type: "cubic"; p1: Coord; cp1: Coord; cp2: Coord; p2: Coord }; - -function buildSegments(points: readonly PointSnapshot[], closed: boolean): Segment[] { - const segments: Segment[] = []; - const n = points.length; - if (n < 2) return segments; - - // Walk through points collecting on-curve to on-curve segments - // Off-curve points between two on-curves form the control points - const allPoints = closed ? [...points, points[0]] : points; - let i = 0; - - while (i < allPoints.length - 1) { - const start = allPoints[i]; - - if (Validate.isOffCurve(start)) { - i++; - continue; - } - - // Collect off-curve points until next on-curve - const offCurves: PointSnapshot[] = []; - let j = i + 1; - while (j < allPoints.length && Validate.isOffCurve(allPoints[j])) { - offCurves.push(allPoints[j]); - j++; - } - - if (j >= allPoints.length) break; - const end = allPoints[j]; - - switch (offCurves.length) { - case 0: - segments.push({ type: "line", p1: start, p2: end }); - break; - case 1: - segments.push({ type: "quad", p1: start, cp: offCurves[0], p2: end }); - break; - case 2: - segments.push({ type: "cubic", p1: start, cp1: offCurves[0], cp2: offCurves[1], p2: end }); - break; - default: - // Multiple off-curves: treat as cubic with first two - segments.push({ type: "cubic", p1: start, cp1: offCurves[0], cp2: offCurves[1], p2: end }); - break; - } - - i = j; - } - - return segments; -} diff --git a/apps/desktop/src/renderer/src/lib/model/Font.ts b/apps/desktop/src/renderer/src/lib/model/Font.ts index 29f76344..3a05da47 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.ts @@ -5,9 +5,9 @@ import type { Axis, Source, Location, + GlyphVariationData, } from "@shift/types"; -import type { MasterSnapshot } from "@/lib/interpolation/interpolate"; -import type { InterpolationResult } from "@/bridge/NativeBridge"; +import type { MasterSnapshot } from "@shift/types"; import type { Bounds } from "@shift/geo"; import { signal, type WritableSignal, type Signal } from "@/lib/reactive/signal"; import type { NativeBridge } from "@/bridge"; @@ -134,12 +134,9 @@ export class Font { return this.#bridge.getGlyphMasterSnapshots(glyphName); } - /** @knipclassignore — interpolate a glyph at a designspace location in Rust */ - interpolateGlyph( - glyphName: string, - location: Record, - ): InterpolationResult | null { - return this.#bridge.interpolateGlyph(glyphName, location); + /** @knipclassignore — used by VariationPanel component */ + getGlyphVariationData(glyphName: string): GlyphVariationData | null { + return this.#bridge.getGlyphVariationData(glyphName); } composites(glyphName: string): CompositeGlyph | null { diff --git a/apps/desktop/src/renderer/src/lib/model/Glyph.ts b/apps/desktop/src/renderer/src/lib/model/Glyph.ts index 30a3ae4e..5330fe1f 100644 --- a/apps/desktop/src/renderer/src/lib/model/Glyph.ts +++ b/apps/desktop/src/renderer/src/lib/model/Glyph.ts @@ -522,6 +522,62 @@ export class Glyph { } } + /** + * @knipclassignore — used by VariationPanel for live interpolation + * + * Apply interpolated values from variation math. + * + * `values` order MUST match `flatten()` in crates/shift-core/src/interpolation.rs: + * [xAdvance, p0.x, p0.y, p1.x, p1.y, ..., a0.x, a0.y, ...] + * + * In-place patch — reuses Point/Contour/Anchor identities, fires per-contour + * signals via a single batch. No struct allocation tree, no JSON parse, no + * NAPI hop on the hot path. + * + * Length-checked at runtime to catch drift between Rust's flatten() walk and + * this one. Round-trip-tested in interpolate.test.ts (parity test ensures + * the values themselves are correct). + */ + applyValues(values: Float64Array): void { + const contours = this.#contours.peek(); + const anchors = this.#anchors.peek(); + + let expected = 1; // xAdvance + for (const c of contours) expected += c.points.length * 2; + expected += anchors.length * 2; + + if (values.length !== expected) { + throw new Error( + `Glyph.applyValues: length mismatch — got ${values.length}, expected ${expected}. ` + + `flatten() in shift-core::interpolation may have drifted from this walk.`, + ); + } + + batch(() => { + let i = 0; + this.#xAdvance.set(values[i++]); + + for (const contour of contours) { + contour._setPoints( + contour.points.map((pt) => { + const x = values[i++]; + const y = values[i++]; + return { ...pt, x, y }; + }), + ); + } + this.#contours.set([...contours]); + + this.#anchors.set( + anchors.map((a) => { + const x = values[i++]; + const y = values[i++]; + return { ...a, x, y }; + }), + ); + }); + } + /** Extract current reactive state as a plain snapshot (for undo, Rust sync). */ toSnapshot(): GlyphSnapshot { return { diff --git a/crates/shift-backends/src/designspace/reader.rs b/crates/shift-backends/src/designspace/reader.rs index 692297dd..49e98897 100644 --- a/crates/shift-backends/src/designspace/reader.rs +++ b/crates/shift-backends/src/designspace/reader.rs @@ -51,8 +51,7 @@ impl FontReader for DesignspaceReader { // Add axes. for ds_axis in &doc.axes { - let minimum = ds_axis.minimum.unwrap_or(ds_axis.default) as f64; - let maximum = ds_axis.maximum.unwrap_or(ds_axis.default) as f64; + let (minimum, maximum) = derive_axis_range(ds_axis); let mut axis = Axis::new( ds_axis.tag.clone(), ds_axis.name.clone(), @@ -192,3 +191,131 @@ fn find_layer_by_name(font: &Font, name: &str) -> Option { .find(|(_, layer)| layer.name() == name) .map(|(&id, _)| id) } + +/// Derive (minimum, maximum) for an axis from norad's parsed designspace. +/// +/// Designspace axis edge cases handled: +/// - **Continuous** (both min/max present): use as-is. +/// - **Discrete** (`values="0 1"` with no min/max attrs): min/max are the +/// smallest/largest values in the list. e.g. `ital`, our `SLAB`. +/// - **One-sided** (only min OR max specified): the missing side falls back +/// to `default`. Common with slant axes (`min=-15, default=0, max=0`). +/// - **Degenerate** (no min/max/values): all three collapse to default. +fn derive_axis_range(ds_axis: &norad::designspace::Axis) -> (f64, f64) { + let values_range = || { + ds_axis + .values + .as_ref() + .filter(|v| !v.is_empty()) + .map(|values| { + let min = values.iter().cloned().fold(f32::INFINITY, f32::min) as f64; + let max = values.iter().cloned().fold(f32::NEG_INFINITY, f32::max) as f64; + (min, max) + }) + }; + + match (ds_axis.minimum, ds_axis.maximum) { + (Some(min), Some(max)) => (min as f64, max as f64), + (None, None) => values_range().unwrap_or((ds_axis.default as f64, ds_axis.default as f64)), + (Some(min), None) => (min as f64, ds_axis.default as f64), + (None, Some(max)) => (ds_axis.default as f64, max as f64), + } +} + +#[cfg(test)] +mod axis_range_tests { + use super::*; + use norad::designspace::Axis as DsAxis; + + fn axis(min: Option, max: Option, default: f32, values: Option>) -> DsAxis { + DsAxis { + name: "test".into(), + tag: "TEST".into(), + minimum: min, + maximum: max, + default, + hidden: false, + values, + ..Default::default() + } + } + + #[test] + fn continuous_uses_explicit_min_max() { + let a = axis(Some(100.0), Some(900.0), 400.0, None); + assert_eq!(derive_axis_range(&a), (100.0, 900.0)); + } + + #[test] + fn discrete_two_values_derives_range() { + // SLAB axis pattern: + let a = axis(None, None, 0.0, Some(vec![0.0, 1.0])); + assert_eq!(derive_axis_range(&a), (0.0, 1.0)); + } + + #[test] + fn discrete_three_values_derives_range_from_extremes() { + let a = axis(None, None, 1.0, Some(vec![0.5, 1.0, 1.5])); + assert_eq!(derive_axis_range(&a), (0.5, 1.5)); + } + + #[test] + fn discrete_unsorted_values_still_finds_extremes() { + let a = axis(None, None, 1.0, Some(vec![1.5, 0.5, 1.0])); + assert_eq!(derive_axis_range(&a), (0.5, 1.5)); + } + + #[test] + fn explicit_min_max_takes_precedence_over_values() { + let a = axis(Some(0.0), Some(2.0), 1.0, Some(vec![0.5, 1.5])); + assert_eq!(derive_axis_range(&a), (0.0, 2.0)); + } + + #[test] + fn one_sided_min_only_falls_back_to_default_for_max() { + // slant-like, half-spec'd: min=-15, default=0, no max attr + let a = axis(Some(-15.0), None, 0.0, None); + assert_eq!(derive_axis_range(&a), (-15.0, 0.0)); + } + + #[test] + fn one_sided_max_only_falls_back_to_default_for_min() { + let a = axis(None, Some(900.0), 400.0, None); + assert_eq!(derive_axis_range(&a), (400.0, 900.0)); + } + + #[test] + fn no_min_max_no_values_collapses_to_default() { + let a = axis(None, None, 400.0, None); + assert_eq!(derive_axis_range(&a), (400.0, 400.0)); + } + + #[test] + fn empty_values_list_collapses_to_default() { + let a = axis(None, None, 0.0, Some(vec![])); + assert_eq!(derive_axis_range(&a), (0.0, 0.0)); + } + + #[test] + fn asymmetric_default_at_minimum() { + // Older fonts where the Light is the default + let a = axis(Some(400.0), Some(900.0), 400.0, None); + assert_eq!(derive_axis_range(&a), (400.0, 900.0)); + // The Axis itself should still normalise sensibly: + let axis = Axis::new("wght".into(), "Weight".into(), 400.0, 400.0, 900.0); + assert_eq!(axis.normalize(400.0), 0.0); + assert_eq!(axis.normalize(900.0), 1.0); + // Below default: range is zero, must return 0 (no negative ramp). + assert_eq!(axis.normalize(100.0), 0.0); + } + + #[test] + fn asymmetric_one_sided_negative_axis() { + // slnt-like: min=-15, default=0, max=0 + let axis = Axis::new("slnt".into(), "Slant".into(), -15.0, 0.0, 0.0); + assert_eq!(axis.normalize(0.0), 0.0); + assert_eq!(axis.normalize(-15.0), -1.0); + // Above default: range is zero, returns 0 (no positive ramp). + assert_eq!(axis.normalize(5.0), 0.0); + } +} diff --git a/crates/shift-core/src/interpolation.rs b/crates/shift-core/src/interpolation.rs index e381cb8b..6f47c21f 100644 --- a/crates/shift-core/src/interpolation.rs +++ b/crates/shift-core/src/interpolation.rs @@ -6,20 +6,38 @@ use fontdrasil::types::Tag; use fontdrasil::variations::VariationModel; use serde::{Deserialize, Serialize}; use shift_ir::variation::to_fd_location; -use shift_ir::{Axis, Location}; +use shift_ir::Axis; +use ts_rs::TS; -use crate::snapshot::{GlyphGeometry, MasterSnapshot}; +use crate::snapshot::{AnchorSnapshot, ContourSnapshot, GlyphGeometry, MasterSnapshot}; +use crate::{Font, Glyph}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] -pub struct InterpolationResult { - pub geometry: GlyphGeometry, - pub errors: Vec, +#[ts(export, export_to = "../../../packages/types/src/generated/")] +pub struct AxisTent { + pub axis_tag: String, + pub lower: f64, + pub peak: f64, + pub upper: f64, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../../packages/types/src/generated/")] +pub struct GlyphVariationData { + /// One entry per region. Inner = tents on the axes the region depends on. + pub regions: Vec>, + /// Same length as `regions`. Each entry = flat values matching `flatten()` order: + /// [xAdvance, p0.x, p0.y, ..., a0.x, a0.y, ...]. + pub deltas: Vec>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../../packages/types/src/generated/")] pub struct SourceError { + #[ts(type = "number")] pub source_index: usize, pub source_name: String, pub message: String, @@ -40,32 +58,6 @@ fn flatten(geom: &GlyphGeometry) -> Vec { values } -fn reconstruct(template: &GlyphGeometry, values: &[f64]) -> GlyphGeometry { - let mut result = template.clone(); - let mut idx = 0; - - result.x_advance = values[idx]; - idx += 1; - - for contour in &mut result.contours { - for point in &mut contour.points { - point.x = values[idx]; - idx += 1; - point.y = values[idx]; - idx += 1; - } - } - - for anchor in &mut result.anchors { - anchor.x = values[idx]; - idx += 1; - anchor.y = values[idx]; - idx += 1; - } - - result -} - fn check_compatibility(a: &GlyphGeometry, b: &GlyphGeometry) -> Result<(), String> { if a.contours.len() != b.contours.len() { return Err(format!( @@ -94,23 +86,67 @@ fn check_compatibility(a: &GlyphGeometry, b: &GlyphGeometry) -> Result<(), Strin Ok(()) } -pub fn interpolate_glyph( - masters: &[MasterSnapshot], - axes: &[Axis], - target: &Location, -) -> Option { - if masters.len() < 2 { +/// Build per-master snapshots of a single glyph. +/// +/// Pure: no editing-session knowledge. Caller passes the `Glyph` it wants the +/// snapshots from (could be the disk copy or an in-progress editing copy with +/// the live session layer patched in — `shift-node` handles that detour). +/// +/// Returns `None` if the font isn't variable or no source has a non-empty layer +/// for this glyph. +pub fn build_master_snapshots(font: &Font, glyph: &Glyph) -> Option> { + if !font.is_variable() { return None; } + let default_source_id = font.default_source_id(); + let mut masters: Vec = Vec::new(); + + for source in font.sources() { + let layer = match glyph.layer(source.layer_id()) { + Some(l) if !l.contours().is_empty() => l, + _ => continue, + }; + + let contours: Vec = layer + .contours() + .values() + .filter(|c| !c.points().is_empty()) + .map(ContourSnapshot::from) + .collect(); + + let anchors: Vec = layer.anchors_iter().map(AnchorSnapshot::from).collect(); + + masters.push(MasterSnapshot { + source_id: source.id().raw().to_string(), + source_name: source.name().to_string(), + is_default_source: default_source_id == Some(source.id()), + location: source.location().clone(), + geometry: GlyphGeometry { + x_advance: layer.width(), + contours, + anchors, + }, + }); + } + + if masters.is_empty() { + None + } else { + Some(masters) + } +} + +pub fn get_glyph_variation_data( + masters: &[MasterSnapshot], + axes: &[Axis], +) -> Option { let ordered_axes: Vec = axes .iter() .filter_map(|a| Tag::from_str(a.tag()).ok()) .collect(); - let default_master = masters - .iter() - .find(|master| master.location.is_default_axis(axes))?; + let default_master = masters.iter().find(|master| master.is_default_source)?; let mut errors = Vec::new(); let mut points: HashMap> = HashMap::new(); @@ -132,11 +168,24 @@ pub fn interpolate_glyph( let locations_set: HashSet = points.keys().cloned().collect(); let model = VariationModel::new(locations_set, ordered_axes); - let deltas = model.deltas::(&points).ok()?; + let model_deltas = model.deltas::(&points).ok()?; + + let regions: Vec> = model_deltas + .iter() + .map(|(region, _)| { + region + .iter() + .map(|(tag, tent)| AxisTent { + axis_tag: tag.to_string(), + lower: tent.min.into_inner().into_inner(), + peak: tent.peak.into_inner().into_inner(), + upper: tent.max.into_inner().into_inner(), + }) + .collect() + }) + .collect(); - let target_fd = to_fd_location(target, axes); - let result_values: Vec = model.interpolate_from_deltas(&target_fd, &deltas); - let geometry = reconstruct(&default_master.geometry, &result_values); + let deltas: Vec> = model_deltas.into_iter().map(|(_, d)| d).collect(); - Some(InterpolationResult { geometry, errors }) + Some(GlyphVariationData { regions, deltas }) } diff --git a/crates/shift-core/src/snapshot.rs b/crates/shift-core/src/snapshot.rs index 99d66fc5..30df70f5 100644 --- a/crates/shift-core/src/snapshot.rs +++ b/crates/shift-core/src/snapshot.rs @@ -242,8 +242,9 @@ impl CommandResult { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../../packages/types/src/generated/")] pub struct MasterSnapshot { pub source_id: String, pub source_name: String, diff --git a/crates/shift-core/tests/interpolation_parity.rs b/crates/shift-core/tests/interpolation_parity.rs new file mode 100644 index 00000000..1234f0c1 --- /dev/null +++ b/crates/shift-core/tests/interpolation_parity.rs @@ -0,0 +1,206 @@ +//! Parity-test fixture writer. +//! +//! Loads the real MutatorSans designspace, builds variation data for a glyph, +//! computes the expected interpolated values via fontdrasil at a known target, +//! and writes a JSON fixture for the TS parity test (in +//! `apps/desktop/src/renderer/src/lib/interpolation/interpolate.test.ts`) to +//! read back and assert the TS port agrees to within f64 precision. +//! +//! Run: `cargo test -p shift-core --test interpolation_parity`. + +use std::collections::{BTreeMap, HashMap}; +use std::fs; +use std::path::PathBuf; +use std::str::FromStr; + +use fontdrasil::coords::NormalizedLocation; +use fontdrasil::types::Tag; +use fontdrasil::variations::VariationModel; +use serde::Serialize; + +use shift_core::font_loader::FontLoader; +use shift_core::interpolation::{ + build_master_snapshots, get_glyph_variation_data, GlyphVariationData, +}; +use shift_core::snapshot::GlyphGeometry; +use shift_ir::variation::to_fd_location; +use shift_ir::Location; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .to_path_buf() +} + +fn fixture_designspace() -> PathBuf { + workspace_root().join("fixtures/fonts/mutatorsans-variable/MutatorSans.designspace") +} + +fn fixture_output() -> PathBuf { + workspace_root().join("packages/types/__fixtures__/variation_parity.json") +} + +/// Local copy of the `flatten` walk used by `get_glyph_variation_data`. +/// Kept private to the test so production `flatten` can stay private. +/// Order MUST match shift-core::interpolation::flatten exactly. +fn flatten_geometry(g: &GlyphGeometry) -> Vec { + let mut v = vec![g.x_advance]; + for c in &g.contours { + for p in &c.points { + v.push(p.x); + v.push(p.y); + } + } + for a in &g.anchors { + v.push(a.x); + v.push(a.y); + } + v +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct MasterEntry { + source_name: String, + is_default_source: bool, + designspace_location: BTreeMap, + normalised_location: BTreeMap, + /// Flat values at this master, in `flatten()` order. + /// `interpolate(data, normalisedLocation)` must equal this within 1e-9. + expected: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Fixture { + /// For diagnostics — which glyph we sampled. + glyph_name: String, + + /// Mid-designspace target (for the headline parity assertion). + designspace_target: BTreeMap, + normalised_location: BTreeMap, + + /// What the TS `interpolate()` consumes — same shape Rust ships over NAPI. + data: GlyphVariationData, + + /// Ground truth from fontdrasil's `interpolate_from_deltas` at the mid target. + /// TS port must match within ~1e-9. + expected: Vec, + + /// Per-master round-trip data: at each master's location, interpolation must + /// recover that master's exact flat values. Catches unpacking drift between + /// Rust's flatten() and TS's applyValues walk. + masters: Vec, +} + +#[test] +fn write_parity_fixture() { + let designspace = fixture_designspace(); + let loader = FontLoader::new(); + let font = loader + .read_font(designspace.to_str().unwrap()) + .expect("load MutatorSans designspace"); + + // "A" is present in all four corner masters of MutatorSans — safe choice. + const GLYPH: &str = "A"; + let glyph = font.glyph(GLYPH).expect("glyph A missing"); + + let masters = build_master_snapshots(&font, glyph).expect("not variable / no masters"); + assert!( + masters.len() >= 4, + "expected ≥ 4 masters for A, got {}", + masters.len() + ); + + let axes = font.axes(); + let data = get_glyph_variation_data(&masters, axes).expect("variation data"); + + // Pick a non-trivial target — middle of designspace on each axis. + let mut target = Location::new(); + for axis in axes { + let mid = (axis.minimum() + axis.maximum()) / 2.0; + target.set(axis.tag().to_string(), mid); + } + let target_norm = to_fd_location(&target, axes); + + // Compute expected via fontdrasil directly — this is the ground truth. + let ordered_axes: Vec = axes + .iter() + .filter_map(|a| Tag::from_str(a.tag()).ok()) + .collect(); + + let mut points: HashMap> = HashMap::new(); // fontdrasil API takes HashMap + for m in &masters { + let loc = to_fd_location(&m.location, axes); + points.insert(loc, flatten_geometry(&m.geometry)); + } + let model = VariationModel::new(points.keys().cloned().collect(), ordered_axes); + let model_deltas = model.deltas::(&points).expect("compute deltas"); + let expected: Vec = model.interpolate_from_deltas(&target_norm, &model_deltas); + + let designspace_target: BTreeMap = target + .iter() + .map(|(tag, value)| (tag.clone(), *value)) + .collect(); + let normalised_location: BTreeMap = target_norm + .iter() + .map(|(tag, coord)| (tag.to_string(), coord.into_inner().into_inner())) + .collect(); + + // Per-master round-trip data — at each master's location, interpolation + // must equal that master's flat values. + let master_entries: Vec = masters + .iter() + .map(|m| { + let m_norm = to_fd_location(&m.location, axes); + let m_expected = model.interpolate_from_deltas(&m_norm, &model_deltas); + MasterEntry { + source_name: m.source_name.clone(), + is_default_source: m.is_default_source, + designspace_location: m + .location + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect::>(), + normalised_location: m_norm + .iter() + .map(|(t, c)| (t.to_string(), c.into_inner().into_inner())) + .collect::>(), + expected: m_expected, + } + }) + .collect(); + + let fixture = Fixture { + glyph_name: GLYPH.to_string(), + designspace_target, + normalised_location, + data, + expected, + masters: master_entries, + }; + + let out = fixture_output(); + // Trailing newline to match what pre-commit's end-of-file-fixer expects; + // without it the fixer would re-add it, breaking idempotency on re-runs. + let new_json = format!("{}\n", serde_json::to_string_pretty(&fixture).unwrap()); + + // Idempotent: only write when content actually changes. This keeps the + // fixture stable across CI runs and pre-commit hooks (which run cargo + // test) — without it, every run would rewrite the file and the hook + // would flag "files modified after staging." + let needs_write = match fs::read_to_string(&out) { + Ok(existing) => existing != new_json, + Err(_) => true, + }; + if needs_write { + fs::create_dir_all(out.parent().unwrap()).expect("mkdir __fixtures__"); + fs::write(&out, &new_json).expect("write fixture"); + println!("wrote parity fixture → {}", out.display()); + } else { + println!("parity fixture up to date ({})", out.display()); + } +} diff --git a/crates/shift-node/index.d.ts b/crates/shift-node/index.d.ts index cd0a33e5..048a6d72 100644 --- a/crates/shift-node/index.d.ts +++ b/crates/shift-node/index.d.ts @@ -24,8 +24,7 @@ export declare class FontEngine { getSources(): string /** Returns a JSON array of master snapshots for a glyph. */ getGlyphMasterSnapshots(glyphName: string): string | null - /** Interpolate a glyph at a given designspace location. */ - interpolateGlyph(glyphName: string, locationJson: string): string | null + getGlyphVariationData(glyphName: string): string | null startEditSession(glyphRef: JsGlyphRef): void endEditSession(): void hasEditSession(): boolean diff --git a/crates/shift-node/src/font_engine.rs b/crates/shift-node/src/font_engine.rs index 48e9237d..142af615 100644 --- a/crates/shift-node/src/font_engine.rs +++ b/crates/shift-node/src/font_engine.rs @@ -1,6 +1,7 @@ use napi::bindgen_prelude::*; use napi::{Error, Result, Status}; use napi_derive::napi; +use shift_core::interpolation::get_glyph_variation_data; use shift_core::{ composite::{ flatten_component_contours_for_layer as flatten_component_contours, layer_bbox, @@ -10,9 +11,9 @@ use shift_core::{ dependency_graph::DependencyGraph, edit_session::EditSession, font_loader::FontLoader, - snapshot::{CommandResult, GlyphGeometry, GlyphSnapshot, MasterSnapshot, RenderContourSnapshot}, + snapshot::{CommandResult, GlyphSnapshot, MasterSnapshot, RenderContourSnapshot}, AnchorId, BooleanOp, ContourId, Font, FontWriter, Glyph, GlyphLayer, GuidelineId, LayerId, - Location, NodePositionUpdate, NodeRef, PasteContour, PointId, PointType, UfoWriter, + NodePositionUpdate, NodeRef, PasteContour, PointId, PointType, UfoWriter, }; use std::collections::HashSet; @@ -503,86 +504,38 @@ impl FontEngine { Some(to_json(&masters)) } - /// Interpolate a glyph at a given designspace location. #[napi] - pub fn interpolate_glyph(&self, glyph_name: String, location_json: String) -> Option { + pub fn get_glyph_variation_data(&self, glyph_name: String) -> Option { let masters = self.build_master_snapshots(&glyph_name)?; - let target: Location = serde_json::from_str(&location_json).expect("Invalid location JSON"); let axes = self.font.axes(); - let result = shift_core::interpolation::interpolate_glyph(&masters, axes, &target)?; - Some(to_json(&result)) + let variation_data = get_glyph_variation_data(&masters, axes)?; + + Some(to_json(&variation_data)) } fn build_master_snapshots(&self, glyph_name: &str) -> Option> { - if !self.font.is_variable() { - return None; - } - - let mut temp_glyph; - let glyph = if let (Some(editing), Some(session), Some(layer_id)) = ( - &self.editing_glyph, - &self.current_edit_session, - &self.editing_layer_id, - ) { - if editing.name() == glyph_name { - temp_glyph = editing.clone(); - temp_glyph.set_layer(*layer_id, session.layer().clone()); - &temp_glyph - } else { - self.font.glyph(glyph_name)? - } - } else { - self.font.glyph(glyph_name)? + let editing = self.editing_glyph_for(glyph_name); + let glyph: &Glyph = match &editing { + Some(g) => g, + None => self.font.glyph(glyph_name)?, }; + shift_core::interpolation::build_master_snapshots(&self.font, glyph) + } - let mut masters: Vec = Vec::new(); - - let default_source_id = self.font.default_source_id(); - for source in self.font.sources() { - let layer_id = source.layer_id(); - let layer = match glyph.layer(layer_id) { - Some(l) if !l.contours().is_empty() => l, - _ => continue, - }; - - let primary_unicode = glyph.primary_unicode().unwrap_or(0); - - let contours: Vec<_> = layer - .contours() - .values() - .filter(|c| !c.points().is_empty()) - .map(shift_core::snapshot::ContourSnapshot::from) - .collect(); - - let anchors = layer - .anchors_iter() - .map(shift_core::snapshot::AnchorSnapshot::from) - .collect(); - - let snapshot = GlyphSnapshot { - unicode: primary_unicode, - name: glyph.name().to_string(), - x_advance: layer.width(), - contours, - anchors, - composite_contours: Vec::new(), - active_contour_id: None, - }; - - masters.push(MasterSnapshot { - source_id: source.id().raw().to_string(), - source_name: source.name().to_string(), - is_default_source: default_source_id == Some(source.id()), - location: source.location().clone(), - geometry: GlyphGeometry::from(&snapshot), - }); - } - - if masters.is_empty() { + /// If the glyph currently being edited is `glyph_name`, return a copy with the + /// in-progress session layer patched in. Otherwise `None` — caller falls back + /// to the disk copy. + fn editing_glyph_for(&self, glyph_name: &str) -> Option { + let editing = self.editing_glyph.as_ref()?; + if editing.name() != glyph_name { return None; } + let session = self.current_edit_session.as_ref()?; + let layer_id = *self.editing_layer_id.as_ref()?; - Some(masters) + let mut temp = editing.clone(); + temp.set_layer(layer_id, session.layer().clone()); + Some(temp) } // ═══════════════════════════════════════════════════════════ diff --git a/packages/types/__fixtures__/variation_parity.json b/packages/types/__fixtures__/variation_parity.json new file mode 100644 index 00000000..a7761f3f --- /dev/null +++ b/packages/types/__fixtures__/variation_parity.json @@ -0,0 +1,438 @@ +{ + "glyphName": "A", + "designspaceTarget": { + "wdth": 500.0, + "wght": 500.0 + }, + "normalisedLocation": { + "wdth": 0.5, + "wght": 0.5 + }, + "data": { + "regions": [ + [ + { + "axisTag": "wdth", + "lower": 0.0, + "peak": 0.0, + "upper": 0.0 + }, + { + "axisTag": "wght", + "lower": 0.0, + "peak": 0.0, + "upper": 0.0 + } + ], + [ + { + "axisTag": "wdth", + "lower": 0.0, + "peak": 1.0, + "upper": 1.0 + }, + { + "axisTag": "wght", + "lower": 0.0, + "peak": 0.0, + "upper": 0.0 + } + ], + [ + { + "axisTag": "wdth", + "lower": 0.0, + "peak": 0.0, + "upper": 0.0 + }, + { + "axisTag": "wght", + "lower": 0.0, + "peak": 1.0, + "upper": 1.0 + } + ], + [ + { + "axisTag": "wdth", + "lower": 0.0, + "peak": 1.0, + "upper": 1.0 + }, + { + "axisTag": "wght", + "lower": 0.0, + "peak": 1.0, + "upper": 1.0 + } + ] + ], + "deltas": [ + [ + 396.0, + 20.0, + 0.0, + 60.0, + 0.0, + 200.0, + 700.0, + 165.0, + 700.0, + 75.0, + 164.0, + 325.0, + 164.0, + 325.0, + 200.0, + 75.0, + 200.0, + 332.0, + 0.0, + 376.0, + 0.0, + 231.0, + 700.0, + 192.0, + 700.0, + 175.0, + 661.0, + 222.0, + 661.0, + 222.0, + 700.0, + 175.0, + 700.0 + ], + [ + 794.0, + 30.0, + 0.0, + 37.0, + 0.0, + 412.0, + 0.0, + 405.0, + 0.0, + 170.0, + 60.0, + 620.0, + 60.0, + 620.0, + 54.0, + 170.0, + 54.0, + 755.0, + 0.0, + 764.0, + 0.0, + 389.0, + 0.0, + 380.0, + 0.0, + 395.0, + 3.0, + 398.0, + 3.0, + 398.0, + 0.0, + 395.0, + 0.0 + ], + [ + 344.0, + -30.0, + 0.0, + 190.0, + 0.0, + 134.0, + 100.0, + -61.0, + 100.0, + 35.0, + -44.0, + 255.0, + -44.0, + 255.0, + 130.0, + 35.0, + 130.0, + 58.0, + 0.0, + 354.0, + 0.0, + 383.0, + 100.0, + 102.0, + 100.0, + 29.0, + -121.0, + 252.0, + -121.0, + 252.0, + 100.0, + 29.0, + 100.0 + ], + [ + -244.0, + 0.0, + 0.0, + 63.0, + 0.0, + -106.0, + 0.0, + -149.0, + 0.0, + -70.0, + -60.0, + -260.0, + -60.0, + -260.0, + -44.0, + -70.0, + -44.0, + -345.0, + 0.0, + -224.0, + 0.0, + -73.0, + 0.0, + -194.0, + 0.0, + -189.0, + -3.0, + -42.0, + -3.0, + -42.0, + 0.0, + -189.0, + 0.0 + ] + ] + }, + "expected": [ + 904.0, + 20.0, + 0.0, + 189.25, + 0.0, + 446.5, + 750.0, + 299.75, + 750.0, + 160.0, + 157.0, + 697.5, + 157.0, + 697.5, + 281.0, + 160.0, + 281.0, + 652.25, + 0.0, + 879.0, + 0.0, + 598.75, + 750.0, + 384.5, + 750.0, + 339.75, + 601.25, + 536.5, + 601.25, + 536.5, + 750.0, + 339.75, + 750.0 + ], + "masters": [ + { + "sourceName": "LightCondensed", + "isDefaultSource": true, + "designspaceLocation": { + "wdth": 0.0, + "wght": 0.0 + }, + "normalisedLocation": { + "wdth": 0.0, + "wght": 0.0 + }, + "expected": [ + 396.0, + 20.0, + 0.0, + 60.0, + 0.0, + 200.0, + 700.0, + 165.0, + 700.0, + 75.0, + 164.0, + 325.0, + 164.0, + 325.0, + 200.0, + 75.0, + 200.0, + 332.0, + 0.0, + 376.0, + 0.0, + 231.0, + 700.0, + 192.0, + 700.0, + 175.0, + 661.0, + 222.0, + 661.0, + 222.0, + 700.0, + 175.0, + 700.0 + ] + }, + { + "sourceName": "BoldCondensed", + "isDefaultSource": false, + "designspaceLocation": { + "wdth": 0.0, + "wght": 1000.0 + }, + "normalisedLocation": { + "wdth": 0.0, + "wght": 1.0 + }, + "expected": [ + 740.0, + -10.0, + 0.0, + 250.0, + 0.0, + 334.0, + 800.0, + 104.0, + 800.0, + 110.0, + 120.0, + 580.0, + 120.0, + 580.0, + 330.0, + 110.0, + 330.0, + 390.0, + 0.0, + 730.0, + 0.0, + 614.0, + 800.0, + 294.0, + 800.0, + 204.0, + 540.0, + 474.0, + 540.0, + 474.0, + 800.0, + 204.0, + 800.0 + ] + }, + { + "sourceName": "LightWide", + "isDefaultSource": false, + "designspaceLocation": { + "wdth": 1000.0, + "wght": 0.0 + }, + "normalisedLocation": { + "wdth": 1.0, + "wght": 0.0 + }, + "expected": [ + 1190.0, + 50.0, + 0.0, + 97.0, + 0.0, + 612.0, + 700.0, + 570.0, + 700.0, + 245.0, + 224.0, + 945.0, + 224.0, + 945.0, + 254.0, + 245.0, + 254.0, + 1087.0, + 0.0, + 1140.0, + 0.0, + 620.0, + 700.0, + 572.0, + 700.0, + 570.0, + 664.0, + 620.0, + 664.0, + 620.0, + 700.0, + 570.0, + 700.0 + ] + }, + { + "sourceName": "BoldWide", + "isDefaultSource": false, + "designspaceLocation": { + "wdth": 1000.0, + "wght": 1000.0 + }, + "normalisedLocation": { + "wdth": 1.0, + "wght": 1.0 + }, + "expected": [ + 1290.0, + 20.0, + 0.0, + 350.0, + 0.0, + 640.0, + 800.0, + 360.0, + 800.0, + 210.0, + 120.0, + 940.0, + 120.0, + 940.0, + 340.0, + 210.0, + 340.0, + 800.0, + 0.0, + 1270.0, + 0.0, + 930.0, + 800.0, + 480.0, + 800.0, + 410.0, + 540.0, + 830.0, + 540.0, + 830.0, + 800.0, + 410.0, + 800.0 + ] + } + ] +} diff --git a/packages/types/src/font.ts b/packages/types/src/font.ts index 0ed20719..b826d0ed 100644 --- a/packages/types/src/font.ts +++ b/packages/types/src/font.ts @@ -11,6 +11,12 @@ export type { RenderPointSnapshot, RenderContourSnapshot, GlyphSnapshot, + GlyphGeometry, + MasterSnapshot, + InterpolationResult, + SourceError, + AxisTent, + GlyphVariationData, CommandResult, RuleId, MatchedRule, diff --git a/packages/types/src/generated/AxisTent.ts b/packages/types/src/generated/AxisTent.ts new file mode 100644 index 00000000..ca476146 --- /dev/null +++ b/packages/types/src/generated/AxisTent.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AxisTent = { axisTag: string, lower: number, peak: number, upper: number, }; diff --git a/packages/types/src/generated/GlyphVariationData.ts b/packages/types/src/generated/GlyphVariationData.ts new file mode 100644 index 00000000..da446126 --- /dev/null +++ b/packages/types/src/generated/GlyphVariationData.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AxisTent } from "./AxisTent"; + +export type GlyphVariationData = { +/** + * One entry per region. Inner = tents on the axes the region depends on. + */ +regions: Array>, +/** + * Same length as `regions`. Each entry = flat values matching `flatten()` order: + * [xAdvance, p0.x, p0.y, ..., a0.x, a0.y, ...]. + */ +deltas: Array>, }; diff --git a/packages/types/src/generated/InterpolationResult.ts b/packages/types/src/generated/InterpolationResult.ts new file mode 100644 index 00000000..1e3e8edc --- /dev/null +++ b/packages/types/src/generated/InterpolationResult.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GlyphGeometry } from "./GlyphGeometry"; +import type { SourceError } from "./SourceError"; + +export type InterpolationResult = { geometry: GlyphGeometry, errors: Array, }; diff --git a/packages/types/src/generated/MasterSnapshot.ts b/packages/types/src/generated/MasterSnapshot.ts new file mode 100644 index 00000000..c80e0bf8 --- /dev/null +++ b/packages/types/src/generated/MasterSnapshot.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GlyphGeometry } from "./GlyphGeometry"; +import type { Location } from "./Location"; + +export type MasterSnapshot = { sourceId: string, sourceName: string, isDefaultSource: boolean, location: Location, geometry: GlyphGeometry, }; diff --git a/packages/types/src/generated/SourceError.ts b/packages/types/src/generated/SourceError.ts new file mode 100644 index 00000000..0fa35723 --- /dev/null +++ b/packages/types/src/generated/SourceError.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SourceError = { sourceIndex: number, sourceName: string, message: string, }; diff --git a/packages/types/src/generated/index.ts b/packages/types/src/generated/index.ts index dceeb89f..286f609f 100644 --- a/packages/types/src/generated/index.ts +++ b/packages/types/src/generated/index.ts @@ -9,6 +9,12 @@ export type { AnchorSnapshot } from "./AnchorSnapshot"; export type { RenderPointSnapshot } from "./RenderPointSnapshot"; export type { RenderContourSnapshot } from "./RenderContourSnapshot"; export type { GlyphSnapshot } from "./GlyphSnapshot"; +export type { GlyphGeometry } from "./GlyphGeometry"; +export type { MasterSnapshot } from "./MasterSnapshot"; +export type { InterpolationResult } from "./InterpolationResult"; +export type { SourceError } from "./SourceError"; +export type { AxisTent } from "./AxisTent"; +export type { GlyphVariationData } from "./GlyphVariationData"; export type { CommandResult } from "./CommandResult"; export type { RuleId } from "./RuleId"; export type { MatchedRule } from "./MatchedRule"; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index b599e235..a09fa65a 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -31,6 +31,12 @@ export type { RenderPointSnapshot, RenderContourSnapshot, GlyphSnapshot, + GlyphGeometry, + MasterSnapshot, + InterpolationResult, + SourceError, + AxisTent, + GlyphVariationData, CommandResult, RuleId, MatchedRule, diff --git a/scripts/patch-generated-types.ts b/scripts/patch-generated-types.ts index a2635efc..2ef4ae00 100644 --- a/scripts/patch-generated-types.ts +++ b/scripts/patch-generated-types.ts @@ -25,6 +25,19 @@ const FILE_IMPORTS: Record = { "CommandResult.ts": ["PointId"], }; +/** Match what pre-commit's trailing-whitespace + end-of-file-fixer do, so the + * hooks don't re-modify these files on every commit attempt. ts-rs emits + * trailing whitespace and no terminating newline. */ +function normalizeWhitespace(content: string): string { + return ( + content + .split("\n") + .map((line) => line.replace(/[ \t]+$/, "")) + .join("\n") + .replace(/\n*$/, "") + "\n" + ); +} + function patchFile(filename: string, imports: string[]): boolean { const filePath = path.join(GENERATED_DIR, filename); @@ -36,23 +49,30 @@ function patchFile(filename: string, imports: string[]): boolean { let content = fs.readFileSync(filePath, "utf8"); const importStatement = `import type { ${imports.join(", ")} } from "../ids";\n`; + const tsrsComment = + "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually."; - if (content.includes('from "../ids"')) { - console.log(`✓ ${filename} already patched`); - return true; + const alreadyHasImport = content.includes('from "../ids"'); + + if (!alreadyHasImport) { + if (content.startsWith(tsrsComment)) { + content = tsrsComment + "\n" + importStatement + content.slice(tsrsComment.length); + } else { + content = importStatement + content; + } } - const tsrsComment = - "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually."; + content = normalizeWhitespace(content); - if (content.startsWith(tsrsComment)) { - content = tsrsComment + "\n" + importStatement + content.slice(tsrsComment.length); - } else { - content = importStatement + content; + // Avoid touching disk if nothing actually changed — keeps git unaware. + const existing = fs.readFileSync(filePath, "utf8"); + if (existing === content) { + console.info(`✓ ${filename} already patched`); + return true; } fs.writeFileSync(filePath, content); - console.log(`✅ Patched ${filename} with imports: ${imports.join(", ")}`); + console.info(`✅ Patched ${filename} with imports: ${imports.join(", ")}`); return true; } @@ -68,7 +88,7 @@ function patchNapiDts(): boolean { const importLine = 'import type { ContourId, PointId, AnchorId } from "@shift/types";'; if (content.includes(importLine)) { - console.log("✓ shift-node/index.d.ts already patched"); + console.info("✓ shift-node/index.d.ts already patched"); return true; } @@ -80,12 +100,31 @@ function patchNapiDts(): boolean { } fs.writeFileSync(napiDtsPath, content); - console.log("✅ Patched shift-node/index.d.ts with branded ID imports"); + console.info("✅ Patched shift-node/index.d.ts with branded ID imports"); return true; } +/** Normalize whitespace on every generated file (whether it needs an ID + * import or not). ts-rs emits trailing whitespace + no terminating newline + * — left untouched, pre-commit's trailing-whitespace + end-of-file-fixer + * hooks would re-flag every commit. */ +function normalizeAllGenerated(): void { + if (!fs.existsSync(GENERATED_DIR)) return; + for (const filename of fs.readdirSync(GENERATED_DIR)) { + if (!filename.endsWith(".ts")) continue; + if (filename in FILE_IMPORTS) continue; // patchFile already normalised + const filePath = path.join(GENERATED_DIR, filename); + const original = fs.readFileSync(filePath, "utf8"); + const normalized = normalizeWhitespace(original); + if (original !== normalized) { + fs.writeFileSync(filePath, normalized); + console.info(`✅ Normalised whitespace in ${filename}`); + } + } +} + function main(): void { - console.log("🔧 Patching generated types with branded ID imports...\n"); + console.info("🔧 Patching generated types with branded ID imports...\n"); let success = true; @@ -95,14 +134,16 @@ function main(): void { } } + normalizeAllGenerated(); + if (!patchNapiDts()) { success = false; } - console.log(""); + console.info(""); if (success) { - console.log("✅ All generated types patched successfully!"); + console.info("✅ All generated types patched successfully!"); } else { console.error("❌ Some files could not be patched"); process.exit(1);