Dev.to · 9 min read

I set the Chinese line 3px smaller than the English. It still rendered larger.

I set the Chinese line 3px smaller than the English. It still rendered larger.

I recently counted every place in my app where a line of English sits directly on top of a line of Chinese. There are 62 of them. The rule, as far as I could reconstruct it from the code, was "make the Chinese one notch smaller". That was the whole rule. No ratio, no scale, no minimum. So I measured what those 62 pairs actually render as. The median pair had the Chinese line larger than the English line it was supposed to be subordinate to. This is the write-up: the two numbers that explain it, the reason a font-metrics check never catches it, and the one-line formula I ended up using instead. First, what "smaller" is supposed to mean Font size is not a visible quantity. What you see of a Latin letter is roughly its cap height or x-height; what you see of a Han character is the ideographic box it fills. These are different fractions of the em, so setting both to 12px does not make them look the same size. You can get all three numbers from a canvas without loading any extra code: const ctx = document.createElement('canvas').getContext('2d'); const SANS = '-apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Helvetica Neue", sans-serif'; const CN = '-apple-system, "PingFang SC", "PingFang HK", "Hiragino Sans GB", "Microsoft YaHei", sans-serif'; ctx.font = `14px ${SANS}`; ctx.measureText('H').actualBoundingBoxAscent; // cap height ctx.measureText('x').actualBoundingBoxAscent; // x-height ctx.font = `14px ${CN}`; ctx.measureText('你').actualBoundingBoxAscent; // Han ink height At 14px, on the two stacks my app actually uses, this returns: measurement 14px per em Latin cap height (H) 9.864px 0.705 Latin x-height (x) 7.369px 0.526 Han ink height (你) 11.508px 0.822 The ratio between them is the entire problem: Han ink ÷ cap height = 1.167 Han ink ÷ x-height = 1.562 A Chinese glyph at the same pixel size as a Latin capital is 17% taller in ink. Against lowercase text — which is most of a sentence — it is 56% taller. The comparison you pick depends on what you're pairing with, but both directions point the same way: at equal font-size, Chinese reads bigger. The ratio inversion Now put that constant into the pairs I actually shipped. If the spec ratio is cnSize / enSize, the optical ratio is: optical = spec × 1.167 Across the 62 pairs, here is what I found: value pairs with an explicit size for both lines 62 distinct spec ratios in use 24 spec ratio range 0.591 – 1.733 median spec ratio 0.923 median optical ratio 1.077 pairs where the Chinese renders at or above the English 40 of 62 pairs at spec ratio 1.000 (identical px) 23 Twenty-three pairs set both lines to the exact same size. At spec 1.000 the optical ratio is 1.167 — the "secondary" line is a sixth larger than the primary one, and it is the one carrying the reading load for the user who needs it. The most common intentional pair in the codebase was 14px / 11px. That is a 21% reduction on paper. On screen it is a 9% reduction — 11 × 0.822 = 9.04px of ink against 9.86px of Latin cap height. You paid three pixels of font size and bought an eighth of a step of hierarchy. That is why the pairs kept feeling flat and why every attempt to fix them by shaving another pixel made it worse without making it different. Between en=14, cn=11 and en=14, cn=10 there is no perceptual difference at all; both sit inside the noise of anti-aliasing. Why the font-metrics check never caught this I did have a check. It read fontBoundingBoxAscent and fontBoundingBoxDescent and asserted the Chinese line was shorter. It passed for months. It passed because those two fields describe the primary font in the list, not the font that drew the glyph. Measuring the same character through both stacks makes this obvious: through fontBoundingBoxAscent ink ascent ink descent -apple-system, …, sans-serif on 你 97 82.4 10.2 -apple-system, "PingFang SC", … on 你 106 82.4 10.2 The ink is byte-for-byte identical — the same glyph came out of the same file. But the reported metrics differ by 9 units, because in the first case the numbers came from the Latin font that never rendered anything on that line. So a canvas-based audit happily reports "Latin font metrics, therefore Latin proportions" while the screen is full of PingFang. Any check that reads metrics instead of ink is measuring the wrong font and will never find this class of bug. The stack I wrote is not the stack that runs While pulling those numbers I found something I had assumed wrong for two years. My two families are: --font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Helvetica Neue", sans-serif; --font-cn: -apple-system, "PingFang SC", "PingFang HK", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; -apple-system is first in both. So both should start with the system font, and --font-cn should only differ for Han glyphs. Measure the same string through each family and compare widths at 100px: family list width of Hamburgefonstiv @100px -apple-system alone 790.70 -apple-system, "Helvetica Neue" 764.00 ← Helvetica Neue "Helvetica Neue" alone 764.00 BlinkMacSystemFont alone 718.31 --font-sans (the full list) 718.31 The second row is the tell. Putting -apple-system in front of Helvetica Neue does not produce the system font's width — it produces Helvetica Neue's. The keyword is skipped entirely in Chrome 156. The list falls through to the next entry that exists. --font-sans therefore lands on BlinkMacSystemFont (718.31), and --font-cn falls through -apple-system and lands on PingFang. Which means my two "different" stacks are two genuinely different physical fonts, and they carry different vertical metrics: font ascent font descent line-height: normal system Latin (BlinkMacSystemFont) 0.97em 0.21em 1.18em PingFang SC 1.06em 0.34em 1.40em At the same 14px, with line-height: normal inherited from the body (which in my stylesheet means no line-height declared at all): English line box: 17px Chinese line box: 20px A pair that is supposed to read as one tight unit is off by three pixels of leading on the second line, and those three pixels do not scale with the Chinese size — they come from the font, not from the number. And this is the fragile part: it only works because Chrome drops the keyword. On an engine that honours -apple-system (WebKit does), both stacks resolve to the same system font, Han falls back to PingFang for glyphs but the line box is built from the system font's 1.18em. Same CSS, different vertical rhythm, on the other half of the install base. I build a Capacitor app, so that is not hypothetical — it is iOS versus Android. I could only measure the Chrome side here. Treat the WebKit half as unverified, but do not treat it as safe: depending on which engine skips a keyword is not a layout strategy. What I do instead 1. Choose the Chinese size first, then derive the English one. You cannot pick both from the design; one of them is a constraint. On a phone the Chinese line has a floor around 11px, so: cnSize = enSize × 0.857 × target_optical where 0.857 = 0.705 / 0.822, the ratio that turns a spec ratio into an optical one. Solving for the English size instead: target optical multiplier en=13 en=14 en=16 en=20 0.85 0.728 9.5 10.2 11.6 14.6 0.80 0.686 8.9 9.6 11.0 13.7 0.75 0.643 8.4 9.0 10.3 12.9 Read the middle row. To get a real 20% step under 14px English you need 9.6px Chinese, which is below the legibility floor. Under 13px English you need 8.9px. Below an English size of about 16px, "genuinely smaller Chinese" and "readable Chinese" are mutually exclusive. Any pair in that range is choosing legibility over hierarchy whether or not anyone said so out loud. 2. If the English must stay small, stop using size for hierarchy. Keep both lines at 1:1 — optical 1.167, which is correct for CJK anyway, since the Han box is what the reader's eye is calibrated on — and separate them with colour and weight instead. That is the one lever that does not fight the metrics. 3. Never inherit line-height: normal on a bilingual pair. normal is not a number, it is font metrics, and the two fonts do not agree (1.18 vs 1.40). A unitless value makes them agree — measured at 14px: 1.5 gives 21px and 21px, where normal gives 17px and 20px. 4. Use text-box to control the gap in ink units. line-height positions boxes; the gap a reader perceives is between ink. Chrome 156 supports trimming the box to the glyph edges: .cn { text-box: trim-both cap alphabetic; } Measured on a 14px line at line-height: 1.4: the box goes from 19.59px to 9.86px — 9.86 being exactly the cap height of that font at that size. Now the space between the English line and the Chinese line is a number I chose, not a byproduct of whichever font the engine picked. 5. Don't reach for font-size-adjust here. It is real and it works, but it normalises by x-height, and a Han glyph has no x-height — you would be scaling to a dimension the glyph never uses. Measured on a 14px Chinese line: 0.52 leaves the box at 20px (PingFang's natural ratio), 0.45 drops it to 17px, 0.6 raises it to 23px. Those numbers look like the lever I wanted, and technically they are — but the 0.45 setting also shrank the Chinese glyphs by about 13%, i.e. it silently redid the thing I was trying to control with a different measurement. Two controls for one variable is how you end up back at 62 inconsistent pairs. The two scripts Everything above came out of two files I kept, both dependency-free: audit-bilingual-pairs.mjs walks a source tree, finds every element that declares the Chinese font family with an inline size, pairs it with the nearest preceding size declaration, and prints the delta and ratio distribution. That is where "62 pairs, 24 ratios, median 0.923" came from. It is a static pass — it finds the intent, and it found the inconsistency before I had measured a single pixel. measure-type.mjs plus a probe page drive a headless Chrome over CDP, read cap height / x-height / Han ink from a canvas, and measure real line boxes and baselines with a zero-height inline-block as a baseline probe. That is where every table in this post came from. The order matters: the static pass tells you the rule was never a rule, and the runtime pass tells you what the rule actually became. Neither alone would have made me change anything. The numbers here are from macOS 10.15, Chrome 156, dpr 2, on a Capacitor app whose UI is bilingual in ten locales — each locale's line paired with the Chinese source text it was translated from, which is why the pair exists on every screen rather than on a few. I build NameSoul, the app these measurements come from; the audit scripts above are the part worth reusing on any bilingual interface.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Gadgets & Consumer Tech