Text: Optical kerning spaces pairs from their outlines

Optical kerning was treated exactly like Metrics: the font's kern pairs
and nothing else, so text set with Optical in InDesign came out wider,
rewrapped and went overset in tightly fitted frames.

Now the font's pairs are turned off for Optical text and each pair is
spaced from its outlines (crates/compose/src/optical.rs): every glyph's
ink is sampled into 28 bands across the em, the pair's mean and smallest
ink gap over the bands both glyphs ink are compared with the font's
stem-to-stem gap (the pair nn, or HH then oo), and the pair moves 60% of
the way towards it, keeping 30% of that gap and moving at most -0.12 or
+0.06 em. Latin, Greek and Cyrillic letters, digits and Western
punctuation take part; spaces, marks, missing glyphs, right-to-left
runs, upright and tate-chu-yoko glyphs and pairs across a fallback font
keep their font spacing. Profiles and references are cached, bounded.

Tests: AV, To and r. close up and nn stays; HH closes up under Optical
but not Metrics (fails on main); every change on a pangram stays within
the bounds and spaces keep their width; profile, gap, adjustment and
character-class unit tests.

Docs: typography parity Kerning row and remaining effort, gap 4,
ROADMAP milestone 6 and progress log.

Fixes #181.
This commit is contained in:
Abdelrehman Fouad
2026-10-11 03:25:01 +02:00
parent a9124a0517
commit 62faa8eee2
9 changed files with 330 additions and 11 deletions
Generated
+1
View File
@@ -928,6 +928,7 @@ dependencies = [
"regex",
"unicode-bidi",
"unicode-bidi-mirroring",
"unicode-script",
]
[[package]]
+4 -2
View File
@@ -2,7 +2,7 @@
**Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, full re-measure against InDesign 2026 21.6; restructured to the craftrules progress-docs standard) · **Target:** Adobe InDesign 2026
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (Optical kerning from outlines (#181); 2026-10-10: stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, full re-measure against InDesign 2026 21.6; restructured to the craftrules progress-docs standard) · **Target:** Adobe InDesign 2026
DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON
format + IDML), scriptable by agents (MCP), and available on the web.
@@ -111,7 +111,7 @@ Ranked; detail and estimates in [docs/roadmap.md](docs/roadmap.md), the full lis
3. CI running `cargo xtask ci` on pull requests (4–8 h)
4. Open INDD through a clean-room converter, owner decision on #114 (20–60 h)
5. IDML real-file corpus and fixes (60–100 h)
6. Composition oracle against InDesign; optical kerning; overset rule (80–140 h)
6. Composition oracle against InDesign; optical kerning tuning; overset rule (70–125 h)
7. Start on any GPU; find every installed font (25–50 h)
## Documents
@@ -129,6 +129,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-11** · Optical kerning (#181): pairs are spaced from their outlines (ink gap per band,
pulled towards the font's stem gap, bounded) instead of silently using the font's kern pairs.
- **2026-10-10** · Version 0.6.0; releases add a Linux riscv64 tar.gz (cross-compiled, CLI
smoke-tested under QEMU).
- **2026-10-10** · Readiness per audience: full ~47% (method aligned with the standard, was
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
unicode-bidi = { workspace = true }
unicode-bidi-mirroring = { workspace = true }
unicode-script = { workspace = true }
designcraft-geom = { workspace = true }
designcraft-color = { workspace = true }
designcraft-doc = { workspace = true }
+1
View File
@@ -15,6 +15,7 @@ pub mod breaker;
mod cache;
pub mod hyphen;
mod notes;
pub mod optical;
mod overlay;
mod ruby;
pub mod shape;
+258
View File
@@ -0,0 +1,258 @@
//! Optical kerning: pair spacing measured from the glyph outlines, for text whose kerning is
//! set to Optical.
//!
//! The font's own kern pairs are not used (Optical replaces Metrics, as in InDesign). Instead,
//! each glyph's ink is sampled into horizontal bands across the em, and for each pair the gap
//! between the left glyph's right edge and the right glyph's left edge is read in the bands where
//! both have ink. The pair is then moved part of the way towards the font's natural stem-to-stem
//! gap (the gap of the pair `nn`, or of `HH` then `oo` when the font lacks them), never closer
//! than a fraction of it, and never by more than a small part of the em. `nn` itself stays as the
//! font set it; diagonal pairs (`AV`, `To`) and letter-punctuation pairs (`r.`, `f,`) close up;
//! round pairs such as `oo`, and pairs that already touch, open slightly.
//!
//! Profiles are cached per (face, glyph) and the reference gap per face, so body text in Optical
//! costs one outline walk per distinct glyph.
use std::collections::HashMap;
use std::sync::{Arc, LazyLock, PoisonError, RwLock};
use designcraft_fonts::{FontFace, ScopedFonts};
use designcraft_geom::{BezPath, PathEl, Point};
use crate::shape::Glyph;
/// Horizontal bands across the em, from `TOP` to `BOTTOM` em (outlines are y-down: negative is
/// above the baseline).
const BANDS: usize = 28;
const TOP: f64 = -1.0;
const BOTTOM: f64 = 0.3;
/// How far a pair moves towards the reference gap (1 = all the way).
const PULL: f64 = 0.6;
/// The ink gap a pair keeps at least, as a fraction of the reference gap.
const FLOOR: f64 = 0.3;
/// Tightest and loosest adjustment, in em.
const MAX_TIGHTEN: f64 = -0.12;
const MAX_LOOSEN: f64 = 0.06;
/// A glyph wider or taller than this many em is not measured.
const MAX_EXTENT: f64 = 8.0;
const PROFILE_CACHE_MAX: usize = 20_000;
/// A glyph's ink extent per band, in font units: `None` where the band holds no ink.
#[derive(Clone, Debug)]
pub struct Profile {
bands: [Option<(f64, f64)>; BANDS],
}
impl Profile {
/// Sample `path` (font units, y-down) into bands of the em.
pub fn of(path: &BezPath, upem: f64) -> Profile {
let mut bands = [None; BANDS];
if upem.is_nan() || upem.is_infinite() || upem <= 0.0 {
return Profile { bands };
}
// A glyph drawn far outside its em is no letter (a hostile font): no profile, no kerning.
let bbox = designcraft_geom::Shape::bounding_box(path);
if !(bbox.x0.is_finite() && bbox.y0.is_finite() && bbox.x1.is_finite() && bbox.y1.is_finite())
|| bbox.width() > MAX_EXTENT * upem
|| bbox.height() > MAX_EXTENT * upem
{
return Profile { bands };
}
let y0 = TOP * upem;
let h = (BOTTOM - TOP) * upem / BANDS as f64;
let mut add = |a: Point, b: Point| {
let (ya, yb) = (a.y.min(b.y), a.y.max(b.y));
let first = ((ya - y0) / h).floor().max(0.0);
let last = ((yb - y0) / h).floor().min(BANDS as f64 - 1.0);
if first > last {
return;
}
for i in first as usize..=last as usize {
let (lo, hi) = (y0 + i as f64 * h, y0 + (i + 1) as f64 * h);
// The segment is straight: its x extent within the band is at the clipped ends.
let (xa, xb) = if (b.y - a.y).abs() < 1e-9 {
(a.x, b.x)
} else {
let x_at = |y: f64| a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x);
(x_at(ya.max(lo)), x_at(yb.min(hi)))
};
let (lo_x, hi_x) = (xa.min(xb), xa.max(xb));
if let Some(band) = bands.get_mut(i) {
*band = match *band {
None => Some((lo_x, hi_x)),
Some((l, r)) => Some((l.min(lo_x), r.max(hi_x))),
};
}
}
};
// Curves are flattened to a fraction of a band: finer makes no difference to the bands.
let (mut cur, mut start) = (Point::ZERO, Point::ZERO);
designcraft_geom::kurbo::flatten(path.iter(), h / 4.0, |el| match el {
PathEl::MoveTo(p) => {
cur = p;
start = p;
}
PathEl::LineTo(p) | PathEl::QuadTo(_, p) | PathEl::CurveTo(_, _, p) => {
add(cur, p);
cur = p;
}
PathEl::ClosePath => {
add(cur, start);
cur = start;
}
});
Profile { bands }
}
/// Mean and smallest ink gap between `self` (set first, advancing `adv`) and `next`, over the
/// bands where both have ink; `None` when they share fewer than two bands.
pub fn gap(&self, next: &Profile, adv: f64) -> Option<(f64, f64)> {
let gaps: Vec<f64> = self.bands.iter().zip(next.bands.iter()).filter_map(|(a, b)| Some(adv + b.as_ref()?.0 - a.as_ref()?.1)).collect();
if gaps.len() < 2 {
return None;
}
let mean = gaps.iter().sum::<f64>() / gaps.len() as f64;
let min = gaps.iter().copied().fold(f64::INFINITY, f64::min);
Some((mean, min))
}
}
static PROFILES: LazyLock<RwLock<HashMap<(u32, u32), Arc<Profile>>>> = LazyLock::new(Default::default);
static REFERENCES: LazyLock<RwLock<HashMap<u32, Option<f64>>>> = LazyLock::new(Default::default);
fn profile_of(db: &ScopedFonts<'_>, face: &FontFace, gid: u32) -> Arc<Profile> {
let key = (face.id(), gid);
if let Some(p) = PROFILES.read().unwrap_or_else(PoisonError::into_inner).get(&key) {
return p.clone();
}
let p = Arc::new(Profile::of(&db.outline(face, gid), face.upem));
let mut w = PROFILES.write().unwrap_or_else(PoisonError::into_inner);
if w.len() >= PROFILE_CACHE_MAX {
w.clear();
}
w.entry(key).or_insert(p).clone()
}
/// The font's natural gap between two straight stems, in font units: the mean ink gap of `nn`
/// (or of `HH`, then `oo`, when the font lacks them). `None` when the font has none of them.
fn reference_gap(db: &ScopedFonts<'_>, face: &FontFace) -> Option<f64> {
let id = face.id();
if let Some(r) = REFERENCES.read().unwrap_or_else(PoisonError::into_inner).get(&id) {
return *r;
}
let found = ['n', 'H', 'o'].into_iter().find_map(|c| {
let gid = face.glyph_for(c);
if gid == 0 {
return None;
}
let p = profile_of(db, face, gid);
let (mean, _) = p.gap(&p, face.advance(gid))?;
(mean > 0.0 && mean.is_finite()).then_some(mean)
});
REFERENCES.write().unwrap_or_else(PoisonError::into_inner).insert(id, found);
found
}
/// Whether `c` takes part in optical kerning: letters of the Latin, Greek and Cyrillic scripts,
/// digits and Western punctuation. Spaces, marks, joined and ideographic scripts keep their
/// font spacing.
pub fn kernable(c: char) -> bool {
use unicode_script::{Script, UnicodeScript};
if c.is_ascii_digit() || c.is_ascii_punctuation() || ('\u{2010}'..='\u{2027}').contains(&c) {
return true;
}
c.is_alphabetic() && matches!(c.script(), Script::Latin | Script::Greek | Script::Cyrillic)
}
/// The adjustment for a pair, in font units: how far the first glyph's advance moves.
fn adjustment(reference: f64, mean: f64, min: f64, upem: f64) -> f64 {
let mut adj = (reference - mean) * PULL;
let floor = FLOOR * reference;
if min + adj < floor {
adj = floor - min;
}
adj.clamp(MAX_TIGHTEN * upem, MAX_LOOSEN * upem)
}
/// Kern the pairs of one shaped run optically: the first glyph of each eligible pair gets its
/// advance adjusted. `tracking` is the tracking already added to each advance, in points.
pub(crate) fn kern_run(db: &ScopedFonts<'_>, run: &mut [Glyph], tracking: f64) {
let eligible = |g: &Glyph| {
g.len > 0
&& g.gid != 0
&& g.adv > 0.0
&& g.sx > 0.0
&& !g.upright
&& !g.shaping_rtl
&& g.tcy.is_none()
&& kernable(g.ch)
&& !g.face.glyph_is_mark(g.gid)
};
for i in 0..run.len().saturating_sub(1) {
let Some((a, b)) = run.get(i).zip(run.get(i + 1)) else { break };
if !eligible(a) || !eligible(b) || a.face.id() != b.face.id() {
continue;
}
let face: &FontFace = &a.face;
let Some(reference) = reference_gap(db, face) else { continue };
let (pa, pb) = (profile_of(db, face, a.gid), profile_of(db, face, b.gid));
// The pair's distance as shaped (features such as `palt` change it), without tracking,
// plus the glyphs' own x offsets; in font units.
let adv = (a.adv - tracking + b.dx - a.dx) / a.sx;
let Some((mean, min)) = pa.gap(&pb, adv) else { continue };
let adj = adjustment(reference, mean, min, face.upem);
if adj.is_finite()
&& let Some(g) = run.get_mut(i)
{
g.adv += adj * g.sx;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use designcraft_geom::Rect;
fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> BezPath {
designcraft_geom::Shape::to_path(&Rect::new(x0, y0, x1, y1), 0.1)
}
#[test]
fn profile_reads_a_stem() {
// A stem from x 100..200, baseline to x-height (y -500..0), in a 1000 upem.
let p = Profile::of(&rect(100.0, -500.0, 200.0, 0.0), 1000.0);
let inked: Vec<_> = p.bands.iter().flatten().collect();
assert!(inked.len() >= 9 && inked.len() <= 12, "{} bands", inked.len());
assert!(inked.iter().all(|(l, r)| (*l - 100.0).abs() < 1e-9 && (*r - 200.0).abs() < 1e-9));
// Two such stems 300 apart: the gap is 200 in every band.
let (mean, min) = p.gap(&p, 300.0).unwrap();
assert!((mean - 200.0).abs() < 1e-9 && (min - 200.0).abs() < 1e-9);
}
#[test]
fn no_shared_band_no_gap() {
let high = Profile::of(&rect(0.0, -900.0, 100.0, -700.0), 1000.0);
let low = Profile::of(&rect(0.0, -100.0, 100.0, 0.0), 1000.0);
assert!(high.gap(&low, 200.0).is_none());
}
#[test]
fn adjustment_is_bounded_and_keeps_a_floor() {
// Reference 200: a pair already at 200 doesn't move.
assert!(adjustment(200.0, 200.0, 200.0, 1000.0).abs() < 1e-9);
// A wide pair tightens by PULL of the excess, up to the cap.
assert!((adjustment(200.0, 300.0, 300.0, 1000.0) + 60.0).abs() < 1e-9);
assert!((adjustment(200.0, 1000.0, 1000.0, 1000.0) + 120.0).abs() < 1e-9);
// Tightening never takes the smallest gap under the floor.
assert!((adjustment(200.0, 300.0, 70.0, 1000.0) + 10.0).abs() < 1e-9);
// A touching pair opens, up to the cap.
assert!((adjustment(200.0, 100.0, -50.0, 1000.0) - 60.0).abs() < 1e-9);
}
#[test]
fn kernable_characters() {
assert!(kernable('A') && kernable('é') && kernable('Ж') && kernable('Ω') && kernable('7') && kernable('.') && kernable('“'));
assert!(!kernable(' ') && !kernable('\t') && !kernable('漢') && !kernable('ب') && !kernable('\u{301}'));
}
}
+5 -1
View File
@@ -352,7 +352,8 @@ pub(crate) fn collapse_tcy(glyphs: &mut [Glyph], vertical: bool) {
fn features_for(p: &CharProps) -> Vec<Feature> {
let mut v = Vec::new();
if !matches!(p.kerning, Kerning::Metrics | Kerning::Optical) {
// Optical kerning replaces the font's pairs with measured ones (`crate::optical`).
if p.kerning != Kerning::Metrics {
v.extend(feature("-kern"));
}
if !p.ligatures || p.tracking.abs() > 1e-9 {
@@ -443,6 +444,9 @@ fn shape_run(
let start = out.len();
shape_run_raw(db, text, range, p, env, style, sub, out);
let run = &mut out[start..];
if p.kerning == Kerning::Optical {
crate::optical::kern_run(db, run, p.tracking / 1000.0 * p.size);
}
let em = p.size * p.h_scale;
for g in run.iter_mut().filter(|g| g.adv > 0.0 && !g.ch.is_control()) {
let (mut left, mut right) = (0.0, 0.0);
+52
View File
@@ -260,6 +260,58 @@ fn glyph_scaling_and_letter_spacing_are_applied() {
assert!(wide(0.5) > wide(0.0) + 10.0 * 0.5 * 2.0);
}
/// Width of `text` set at 48 pt with `kerning`, and the advance of each visible glyph.
fn set_with_kerning(text: &str, kerning: designcraft_doc::Kerning) -> (f64, Vec<(char, f64)>) {
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 2000.0, 200.0), ParaAttrs::default());
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| {
f.over.size = Some(48.0);
f.over.kerning = Some(kerning);
});
let cs = compose_story(&d, sid, &ComposeOptions::default());
let l = &cs.frames[0].lines[0];
let advs = l.glyphs.iter().filter(|g| g.visible).map(|g| (text[g.byte..].chars().next().unwrap_or(' '), g.adv)).collect();
(l.end_x - l.x0, advs)
}
#[test]
fn optical_kerning_closes_open_pairs_and_leaves_stems() {
use designcraft_doc::Kerning;
// `AV` and `To` set tighter than unkerned; `nn`, the reference pair, stays as the font set it.
for pair in ["AV", "To", "r."] {
let (none, _) = set_with_kerning(pair, Kerning::None);
let (optical, _) = set_with_kerning(pair, Kerning::Optical);
assert!(optical < none - 0.5, "{pair}: optical {optical:.2} vs none {none:.2}");
}
let (none, _) = set_with_kerning("nn", Kerning::None);
let (optical, _) = set_with_kerning("nn", Kerning::Optical);
assert!((none - optical).abs() < 48.0 * 0.02, "nn: optical {optical:.2} vs none {none:.2}");
// Optical is measured, not the font's pairs: `HH` has no kern pair but its gap is wider
// than `nn`'s, so it closes up under Optical and not under Metrics.
let (metrics, _) = set_with_kerning("HH", Kerning::Metrics);
let (optical, _) = set_with_kerning("HH", Kerning::Optical);
assert!(optical < metrics - 0.5, "HH: optical {optical:.2} vs metrics {metrics:.2}");
}
#[test]
fn optical_kerning_is_bounded_and_skips_spaces() {
use designcraft_doc::Kerning;
let text = "Sphinx of black quartz, judge my vow. 1997 “Park” AWAY";
let (_, none) = set_with_kerning(text, Kerning::None);
let (_, optical) = set_with_kerning(text, Kerning::Optical);
assert_eq!(none.len(), optical.len());
for ((c, a), (_, b)) in none.iter().zip(&optical) {
let em = (b - a) / 48.0;
assert!((-0.12 - 1e-6..=0.06 + 1e-6).contains(&em), "{c:?}: {em:.3} em");
if c.is_whitespace() {
assert!((a - b).abs() < 1e-9, "space before a word keeps its width");
}
}
// The glyph before a space is not kerned against it either.
let (_, none) = set_with_kerning("r ", Kerning::None);
let (_, optical) = set_with_kerning("r ", Kerning::Optical);
assert!((none[0].1 - optical[0].1).abs() < 1e-9);
}
#[test]
fn hyphen_limit_is_a_hard_constraint() {
let text = CORPUS.join(" ");
+4 -4
View File
@@ -1,6 +1,6 @@
# Where DesignCraft falls short of InDesign
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, first version: gaps from the full re-measure and the issue tracker) · **Target:** Adobe InDesign 2026 (21.6)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (Optical kerning from outlines (#181); 2026-10-10: stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, first version: gaps from the full re-measure and the issue tracker) · **Target:** Adobe InDesign 2026 (21.6)
Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an
InDesign professional from doing real work. This is the work list: agents pick from the top.
@@ -69,13 +69,13 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
### 4. Composition isn't compared with InDesign (T)
- **Missing:** an oracle harness: the same synthetic stories set by InDesign 2026 (observed
black-box, results kept under `plan/`) and by DesignCraft, comparing line breaks, overset and
frame fit. Known differences: optical kerning falls back to metrics, so imported text sets
wider (#181); a frame goes overset when descenders don't fit, InDesign needs only the baseline
frame fit. Known differences: optical kerning is an outline-based approximation, not Adobe's
algorithm (#181); a frame goes overset when descenders don't fit, InDesign needs only the baseline
(#180); setting the text language was missing (#130; Language is now in Properties, #145, the issue is still open); "hyphenation doesn't work"
(#87).
- **Impact:** an opened IDML reflows: different line breaks, different page count, overset text.
Professionals notice on the first page.
- **Estimate:** 80–140 h (harness 20–30, optical kerning 15–25, then fixes by cluster).
- **Estimate:** 70–125 h (harness 20–30, optical kerning tuning 5–10, then fixes by cluster).
- **Doc:** [typography-parity.md](typography-parity.md)
### 5. Startup crashes on some graphics hardware (S, H)
+4 -4
View File
@@ -1,6 +1,6 @@
# Typography and composition parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version, from the full re-measure) · **Target:** Adobe InDesign 2026 (21.6)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (Optical kerning from outlines (#181); 2026-10-10: major, first version, from the full re-measure) · **Target:** Adobe InDesign 2026 (21.6)
Typesetting is what InDesign is bought for. A feature can exist and still set text differently
from InDesign: different line breaks, different page count, overset where InDesign fits. This
@@ -14,7 +14,7 @@ against InDesign is **unmeasured**, which is the main gap.
## Fidelity: how we'd know
There is no oracle yet. What exists: unit tests of break points and invariants in
`crates/compose/src/tests.rs` (165 tests in the crate), IDML files built by DesignCraft and opened
`crates/compose/src/tests.rs` (170 tests in the crate), IDML files built by DesignCraft and opened
in InDesign 2026 by hand, and user reports comparing the two (#179, #180, #181, #282–#295).
**Needed (gap 4 in [gaps.md](gaps.md)):** a set of synthetic stories (our own text and fonts:
@@ -38,7 +38,7 @@ Fidelity: **unmeasured** unless stated.
| Justification | word/letter spacing, glyph scaling min/desired/max, single-word, last line | all of these | ✓ | a justified line with a tab now justifies after the tab (#284) |
| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list) and Spanish (Bezos); zone, limits, exceptions in the user dictionary | ~ | other languages don't hyphenate (#87 reported "doesn't work") |
| Spelling | per-language dictionaries | English (Moby) + document/user dictionary | ~ | |
| Kerning | Metrics, Optical, manual, none | Metrics, manual, none | ~ | **Optical treated as Metrics** (#181): imported display text sets wider and can overset |
| Kerning | Metrics, Optical, manual, none | Metrics, Optical (outline-based: pairs pulled towards the font's stem gap, `crates/compose/src/optical.rs`), manual, none | ~ | Optical is an approximation of Adobe's algorithm (#181), not a match: imported display text can still break differently |
| Tracking, scale, skew, baseline shift | ✓ | ✓ | ✓ | |
| OpenType features | ligatures, discretionary, swash, fractions, ordinals, figures, small caps, stylistic sets, positional forms | through harfrust | ✓ | |
| Variable fonts | axes in Character panel | named instances and free axis values; PDF embeds the instance | ✓ | |
@@ -75,7 +75,7 @@ Measured from the table above (2026-10-10): 34 rows, 18 ✓, 16 ~, 0 ✗, scorin
| Work | Opus 5.5 h |
|---|---|
| Oracle harness (synthetic stories, IDML exchange, comparison metric) | 20–30 |
| Optical kerning approximation (outline-based side-bearing model) + preflight warning | 15–25 |
| Optical kerning tuning against InDesign (measured pairs per class, once the oracle harness exists) | 5–10 |
| Overset rule (baseline fits) and frame-fit parity | 5–10 |
| Fixing composer differences the harness finds (weights, penalties, glyph-scaling order) | 40–70 |
| Hyphenation patterns for the top 15 InDesign languages (permissive sources) | 20–35 |