fix(input): derive heading bold from the base font on iOS - #586
fix(input): derive heading bold from the base font on iOS#586wildseansy wants to merge 1 commit into
Conversation
A heading with a bold fontWeight (the shared default) built its font with UIFont systemFontOfSize:weight:, discarding the editor's base font family — custom-font editors got system-font headings. Android already folds heading weight into the current typeface (InputHeadingSpan), so iOS now derives the bold face from the base font via a font-descriptor trait, falling back to the system font only when the family has no bold face. Non-bold explicit weights keep the previous system-font behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
eszlamczyk
left a comment
There was a problem hiding this comment.
Hi @wildseansy overall great catch! While this is a good thing to look for and the fix itself is decent I believe we can make this better by making it more robust than this solution. Please refer to the comments.
Also: If you could drop this comment in the middle I'd be grateful, I think this code is well self-explainatory
| UIFont *font = weightString.length > 0 ? [UIFont systemFontOfSize:size weight:ENRMFontWeightFromString(weightString)] | ||
| : [_baseFont fontWithSize:size]; | ||
| UIFont *font; | ||
| if (weightString.length > 0 && ENRMFontWeightFromString(weightString) >= UIFontWeightBold) { |
There was a problem hiding this comment.
While this is correct, I've checked that ENRMFontWeightFromString is incomplete if we use it for this comparasion; Because the mapping returns UIFontWeightRegular (0.0) for "800", "900", "heavy", and "black", those weights arrive at line 116 as 0.0, fail the >= 0.4 check, and skip the new bold-derivation branch — even though semantically they are bolder than bold.
If we want to use this function in this way we would require to fix the ENRMFontWeightFromString to mirror RN's own RCTFont table:
| Weight strings | UIFont.Weight constant |
|---|---|
100, ultralight |
UIFontWeightUltraLight |
200, thin |
UIFontWeightThin |
300, light |
UIFontWeightLight |
400, normal, regular |
UIFontWeightRegular |
500, medium |
UIFontWeightMedium |
600, semibold |
UIFontWeightSemibold |
700, bold |
UIFontWeightBold |
800, heavy |
UIFontWeightHeavy |
900, black |
UIFontWeightBlack |
| ---- (default) | UIFontWeightRegular |
| font = descriptor ? [UIFont fontWithDescriptor:descriptor size:size] | ||
| : [UIFont systemFontOfSize:size weight:UIFontWeightBold]; | ||
| } else if (weightString.length > 0) { | ||
| font = [UIFont systemFontOfSize:size weight:ENRMFontWeightFromString(weightString)]; |
There was a problem hiding this comment.
The new bold branch correctly derives the heading font from the base font's family, but the else if below it still routes every sub-bold weight ('600', '500', '300', …) through [UIFont systemFontOfSize:weight:], which always produces the system font. So with a custom editor font (say Arial) and h1: { fontWeight: '600' }:
- iOS editor → system font semibold (family lost)
- Android editor → Arial (
InputHeadingSpannever leaves the base typeface) - iOS preview → Arial semibold (
RCTFontresolves the weight within the family)
This is the same divergence this PR fixes for bold, just for the remaining weights — and the family may well have matching faces (Arial Light/Medium/etc.) we could use.
Suggestion: instead of special-casing bold via the symbolic trait, derive every non-empty weight from the base font's descriptor using the weight trait, e.g.
UIFontDescriptor *descriptor = [_baseFont.fontDescriptor fontDescriptorByAddingAttributes:@{
UIFontDescriptorTraitsAttribute : @{UIFontWeightTrait : @(weight)}
}];The system picks the closest face in the family to the requested weight, which would collapse the bold branch and this one into a single path (keeping the system-font fallback for families where descriptor resolution fails).
| // InputHeadingSpan, which folds heading weight into the current typeface — | ||
| // so a custom editor font keeps its family in headings. Fall back to the | ||
| // system font only when the family has no bold face. | ||
| UIFont *sized = [_baseFont fontWithSize:size]; |
There was a problem hiding this comment.
Nitpick - Two small things in this hunk:
sizedis a throwaway - symbolic traits don't depend on point size, so the descriptor can come straight from_baseFont, andfontWithDescriptor:size:already applies the size:
UIFontDescriptor *descriptor = [_baseFont.fontDescriptor
fontDescriptorWithSymbolicTraits:_baseFont.fontDescriptor.symbolicTraits | UIFontDescriptorTraitBold];- ENRMFontWeightFromString(weightString) runs twice (here and in the else if),
and weightString.length > 0 is checked twice. Computing the weight once above
the if simplifies both branches:
UIFontWeight weight = ENRMFontWeightFromString(weightString);
if (weightString.length > 0 && weight >= UIFontWeightBold) {
...
} else if (weightString.length > 0) {
font = [UIFont systemFontOfSize:size weight:weight];
}Non-blocking — and if we go with the UIFontWeightTrait approach from the other
comment, most of this restructures anyway, so feel free to fold it in there instead.
Summary
A heading with a bold
fontWeight(the shared default fromheadingDefaults) builds its font withUIFont systemFontOfSize:weight:, which discards the editor's base font family — an editor configured with a custom font gets system-font headings. Android does not have this problem:InputHeadingSpanfolds the heading weight into the current typeface viaTypeface.create(tp.typeface, BOLD).Change
iOS now derives the bold face from the base font via a font-descriptor symbolic trait (the same approach
fontForTraits:already uses for inline bold), falling back to the system font only when the family has no bold face. Non-bold explicit weights keep the existing system-font behavior.Testing
clang-formatclean on the touched file.🤖 Generated with Claude Code