diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -359,6 +359,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
 Newest first.
 
 - **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change.
+- **2026-10-24** · Text frames: a Cap Height first baseline is measured from the font's flat capital outline (H), not only its declared OS/2 cap height, which some fonts round or understate. In a font that declares a 500-unit cap height but draws its H at 656 units, the first line sat 1.56 pt too high at 10 pt (3.12 pt at 20 pt), and every following line kept the error. Inline objects, hidden text and scaled drop caps use the same metric. No readiness percentage change.
 - **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change.
 - **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change.
 - **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change.
diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -3078,7 +3078,8 @@ fn line_metrics(
     if src.is_empty() {
         let face = face();
         let k = base_size / face.upem;
-        let tops = Tops { typo_ascent: face.typo_ascent * k, cap: face.cap_height * k, xh: face.x_height * k, em: em_box_top(&face, base_size) };
+        let tops =
+            Tops { typo_ascent: face.typo_ascent * k, cap: face.baseline_cap_height * k, xh: face.x_height * k, em: em_box_top(&face, base_size) };
         return (face.ascent * k, tops, face.descent * k, base_leading);
     }
     let mut asc: f64 = 0.0;
@@ -3099,7 +3100,7 @@ fn line_metrics(
             // The em box sits at the unshifted baseline: a baseline shift moves only the drawn glyphs.
             tops.em = tops.em.max(em_box_top(&g.face, g.size) + align.max(0.0));
         }
-        tops.cap = tops.cap.max(g.cap + up);
+        tops.cap = tops.cap.max(g.baseline_cap + up);
         tops.xh = tops.xh.max(g.xh + up);
         desc = desc.max(g.descent - shift.min(0.0));
         lead = lead.max(g.leading);
@@ -3970,6 +3971,7 @@ fn split_drop_cap(
         g.typo_ascent *= k;
         g.descent *= k;
         g.cap *= k;
+        g.baseline_cap *= k;
         g.xh *= k;
         g.size *= k;
         g.space *= k;
diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs
--- a/crates/compose/src/shape.rs
+++ b/crates/compose/src/shape.rs
@@ -38,9 +38,11 @@ pub struct Glyph {
     pub descent: f64,
     /// The leading this character asks for (absolute, or auto = size × auto %).
     pub leading: f64,
-    /// Cap height and x height, scaled like `ascent`: the Cap Height and x Height first baseline
-    /// offsets.
+    /// Declared cap height and x height, scaled like `ascent`. The physical first-baseline cap
+    /// height is kept separately below.
     pub cap: f64,
+    /// Physical cap height for the first baseline, separately from the declared cap metric.
+    pub baseline_cap: f64,
     pub xh: f64,
     pub size: f64,
     /// Index into the composed story's [`RunStyle`] table.
@@ -487,7 +489,7 @@ pub(crate) fn shape_para(
                 }
                 let mut g = control_glyph(&face, &first.props, first.env, first.style, first.range.start + k, c);
                 g.ch = HIDDEN;
-                (g.ascent, g.typo_ascent, g.descent, g.leading, g.cap, g.xh) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
+                (g.ascent, g.typo_ascent, g.descent, g.leading, g.cap, g.baseline_cap, g.xh) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
                 glyphs.push(g);
             }
             i += 1;
@@ -884,7 +886,7 @@ fn shape_run_raw(
                                 // The replacement character has no font ink. Its box alone
                                 // supplies extents; adjacent text supplies its own font metrics.
                                 let top = (o.h + y).max(0.0);
-                                (g.ascent, g.typo_ascent, g.cap, g.xh) = (top, top, top, top);
+                                (g.ascent, g.typo_ascent, g.cap, g.baseline_cap, g.xh) = (top, top, top, top, top);
                                 g.descent = (-y).max(0.0);
                             }
                             // Pushes its line down by its height and spacing.
@@ -892,6 +894,7 @@ fn shape_run_raw(
                                 g.ascent += o.h + o.space;
                                 g.typo_ascent += o.h + o.space;
                                 g.cap += o.h + o.space;
+                                g.baseline_cap += o.h + o.space;
                                 g.xh += o.h + o.space;
                             }
                         }
@@ -1069,6 +1072,7 @@ fn control_glyph(face: &Arc<FontFace>, p: &CharProps, auto_leading: TypeEnv, sty
         descent,
         leading,
         cap,
+        baseline_cap: face.baseline_cap_height * k * p.v_scale,
         xh,
         size,
         style,
@@ -1226,6 +1230,7 @@ fn shape_segment(
             descent,
             leading,
             cap,
+            baseline_cap: face.baseline_cap_height * k * p.v_scale,
             xh,
             size,
             style: styles.at(byte),
diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs
--- a/crates/compose/src/tests.rs
+++ b/crates/compose/src/tests.rs
@@ -3026,7 +3026,7 @@ fn ascent_first_baseline_uses_the_typographic_ascender() {
     assert!(close(at(FirstBaseline::Ascent, 18.0), top + 18.0));
     assert!(close(at(FirstBaseline::Ascent, 10.0), top + 14.0));
     // The other kinds don't use it.
-    assert!(close(at(FirstBaseline::CapHeight, 0.0), top + 0.660 * 20.0));
+    assert!(close(at(FirstBaseline::CapHeight, 0.0), top + 0.656 * 20.0));
     assert!(close(at(FirstBaseline::XHeight, 0.0), top + 0.486 * 20.0));
     assert!(close(at(FirstBaseline::Leading, 0.0), top + 24.0));
     assert!(close(at(FirstBaseline::Fixed, 0.0), top));
@@ -3296,11 +3296,11 @@ fn cap_height_and_x_height_first_baselines_use_the_fonts_cap_and_x_heights() {
         })
     };
     let close = |a: f64, b: f64| (a - b).abs() < 0.01;
-    // Cap height 0.5 em and x height 0.3 em of 20 pt, not 0.72 / 0.5 of the 1 em ascent.
-    assert!(close(at(FirstBaseline::CapHeight, 0.0), top + 10.0), "{}", at(FirstBaseline::CapHeight, 0.0));
+    // Physical H height 0.656 em and declared x height 0.3 em, not shares of the ascent.
+    assert!(close(at(FirstBaseline::CapHeight, 0.0), top + 13.12), "{}", at(FirstBaseline::CapHeight, 0.0));
     assert!(close(at(FirstBaseline::XHeight, 0.0), top + 6.0), "{}", at(FirstBaseline::XHeight, 0.0));
     // The minimum offset still wins when it is larger.
-    assert!(close(at(FirstBaseline::CapHeight, 12.0), top + 12.0));
+    assert!(close(at(FirstBaseline::CapHeight, 16.0), top + 16.0));
     assert!(close(at(FirstBaseline::XHeight, 5.0), top + 6.0));
     // Vertical scale and a raised baseline count, as for the ascent.
     let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default());
@@ -3315,7 +3315,7 @@ fn cap_height_and_x_height_first_baselines_use_the_fonts_cap_and_x_heights() {
     tf.options.inset = [4.0, 0.0, 0.0, 0.0];
     tf.options.first_baseline = FirstBaseline::CapHeight;
     let first = |d: &Document| compose_story(d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline;
-    assert!(close(first(&d), top + 15.0 + 1.0), "{}", first(&d));
+    assert!(close(first(&d), top + 19.68 + 1.0), "{}", first(&d));
     d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::XHeight;
     assert!(close(first(&d), top + 9.0 + 1.0), "{}", first(&d));
 }
@@ -7746,3 +7746,14 @@ fn anchored_text_frames_compose_against_the_grid_where_they_are_placed() {
     Arc::make_mut(&mut d.spreads[1]).pages[1].side = designcraft_doc::PageSide::Left;
     assert!(!Arc::ptr_eq(&before, &cache.get(&d, nsid, None)));
 }
+
+#[test]
+fn cap_height_first_baseline_uses_the_capital_outline_when_the_metric_disagrees() {
+    use designcraft_doc::FirstBaseline;
+    let db = designcraft_fonts::FontDb::global();
+    db.add_font(test_font("CapMetricDiff", Some(&[(CAP_HEIGHT, 500)])));
+    // Source Sans 3's H reaches 656 units, despite this synthetic font declaring 500.
+    // A Cap Height first baseline must clear the actual flat capital, at any point size.
+    let actual = first_baseline_in("CapMetricDiff", |o| o.first_baseline = FirstBaseline::CapHeight);
+    assert!((actual - (40.0 + 20.0 * 0.656)).abs() < 0.001, "baseline {actual}");
+}
diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs
--- a/crates/fonts/src/fontdb.rs
+++ b/crates/fonts/src/fontdb.rs
@@ -112,10 +112,11 @@ pub struct FontFace {
     pub typo_ascent: f64,
     /// Descender in font units (positive = down).
     pub descent: f64,
-    /// Cap height and x height in font units (without plausible OS/2 values, the cap height is the
-    /// top of the H, and either is estimated from the ascent): the Cap Height and x Height first
-    /// baseline offsets.
+    /// Declared cap height and x height in font units. Without plausible OS/2 values, measure
+    /// flat letters, then estimate from the ascent. First-baseline caps use `baseline_cap_height`.
     pub cap_height: f64,
+    /// Physical flat-cap top for Cap Height first baselines; falls back to the declared cap height.
+    pub baseline_cap_height: f64,
     pub x_height: f64,
     pub shaper: harfrust::ShaperData,
     /// Variable fonts: the named instance's axis settings (user units; empty = default instance),
@@ -932,6 +933,11 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s
         cap_height: plausible(m.cap_height.map(f64::from), upem)
             .or_else(|| measured_top(&f, (&location).into(), &['H', 'I', 'E', 'T'], upem))
             .unwrap_or(m.ascent as f64 * 0.72),
+        // First-baseline placement follows the physical capital (H), even when a font's
+        // rounded OS/2 sCapHeight differs. Keep the declared metric for other typography.
+        baseline_cap_height: measured_top(&f, (&location).into(), &['H', 'I', 'E', 'T'], upem)
+            .or_else(|| plausible(m.cap_height.map(f64::from), upem))
+            .unwrap_or(m.ascent as f64 * 0.72),
         x_height: plausible(m.x_height.map(f64::from), upem)
             .or_else(|| measured_top(&f, (&location).into(), &['x', 'z', 'v'], upem))
             .unwrap_or(m.ascent as f64 * 0.5),
