Tajawal(投稿時点のコミットハッシュ:2085b89)をVivliostyleで使用していたところ、WindowsとLinux(&macOS)で組版結果が異なることに気づきました。Playwrightを直接使用して確認してみても同様の問題が発生することがわかりました。
| Linux | Windows |
|---|---|
![]() |
![]() |
再現用ソースコード
<html>
<head>
<style>
@font-face {
font-family: "Tajawal";
src: url("Tajawal-Regular.ttf");
}
</style>
</head>
<body style="margin: 0">
<div
style="
margin: 0;
width: 100px;
height: 100px;
background-color: lightgray;
"
></div>
<p
style='
margin: 0;
font-family: "Tajawal";
font-size: 48px;
position: absolute;
left: 100px;
top: 100px;
border: solid black 1px;
'
>
0
</p>
</body>
</html>
import os from "node:os";
import path from "node:path";
import * as url from "node:url";
import { chromium } from "playwright";
const indexPath = path.resolve(
path.dirname(url.fileURLToPath(import.meta.url)),
"index.html",
);
const indexUrl = url.pathToFileURL(indexPath).toString();
const browser = await chromium.launch();
try {
const page = await browser.newPage();
await page.goto(indexUrl);
await page.screenshot({
path: `screenshot.${os.platform()}.png`,
clip: { x: 0, y: 0, width: 300, height: 300 },
});
} finally {
await browser.close();
}
メモ:
> curl -O https://raw.githubusercontent.com/googlefonts/tajawal/2085b8942f234e7afb83dc03c77713d0d5471cc9/fonts/ttf/Tajawal-Regular.ttf
> docker run --rm --interactive --tty --mount type=bind,source=.,target=/workdir --entrypoint /bin/bash --workdir /workdir node:lts
# npm install && npx playwright install --with-deps chromium
原因はフォント側のメタデータにあります。OpenTypeフォントは縦方向のメトリクスをOS/2テーブルのsTypoAscender/sTypoDescender、同じくOS/2のusWinAscent/usWinDescent、hheaテーブルのascent/descentという3系統で持っています。OS/2.fsSelectionのUSE_TYPO_METRICSビット(bit 7)が立っているフォントは全プラットフォームがsTypo*を使用しますが、立っていない場合のフォールバック先がプラットフォームごとに分かれます。ChromiumはSkiaを介してフォントメトリクスを取得しており、Linuxビルドでは Skia 自身がfsSelectionを見てhheaにフォールバックします1。一方 Windows / macOS ビルドではOSのフォントAPI(DirectWrite2 / CoreText3)の戻り値をそのまま使うため、各APIの慣習に従ってそれぞれusWin*・hheaが返されます。TajawalはUSE_TYPO_METRICSビットが立っておらず、かつusWin*とhheaの値が大きくずれているため、組版結果がOS依存になっていました。
Tajawal(OFL-1.1)のようにフォントを直接修正して再配布することがライセンス上許容されている場合はUSE_TYPO_METRICSビットを立てることで根本的に対処できます。
import fontTools.ttLib
font = fontTools.ttLib.TTFont("Tajawal-Regular.ttf")
os2 = font["OS/2"]
# USE_TYPO_METRICS(fsSelection bit 7)はOS/2テーブルversion 4以上で定義される
if os2.version < 4:
os2.version = 4
os2.fsSelection |= 1 << 7
font.save("Tajawal-Regular.patched.ttf")
$ uv run --with fonttools patch.py
しかしプロプライエタリのフォントではこの対処はできません。例えば同じ問題をDIN Condensedでも確認できました。この場合はCSSの@font-face記述子で対処します。ascent-override・descent-override・line-gap-overrideプロパティで、フォント側のメトリクス値を上書きできます。
適切な値を設定するために、Pythonスクリプトでフォントのメトリクス情報を調べます(DIN Condensedは読者の手元で再現できないため、引き続きTajawalを例に対処方法を示します)。
import fontTools.ttLib
def get_font_metrics_for_override(path: str):
font = fontTools.ttLib.TTFont(path)
units_per_em = font["head"].unitsPerEm
os2 = font["OS/2"]
ascent = os2.sTypoAscender
descent = abs(os2.sTypoDescender)
line_gap = os2.sTypoLineGap
ascent_override = 100 * ascent / units_per_em
descent_override = 100 * descent / units_per_em
line_gap_override = 100 * line_gap / units_per_em
return [ascent_override, descent_override, line_gap_override]
[ascent_override, descent_override, line_gap_override] = get_font_metrics_for_override(
"Tajawal-Regular.ttf"
)
print(f"ascent-override: {ascent_override}%;")
print(f"descent-override: {descent_override}%;")
print(f"line-gap-override: {line_gap_override}%;")
$ uv run --with fonttools main.py
ascent-override: 64.3%;
descent-override: 35.7%;
line-gap-override: 20.0%;
上記で得られた値を使って、CSSの@font-faceルールを修正します。
@font-face {
font-family: "Tajawal";
src: url("Tajawal-Regular.ttf");
+ ascent-override: 64.3%;
+ descent-override: 35.7%;
+ line-gap-override: 20.0%;
}
| Linux | Windows |
|---|---|
![]() |
![]() |
完全一致とはいきませんが実用上問題ない程度に改善することができました。
-
src/ports/SkFontHost_FreeType.cpp(Skia HEADa87f066)。fsSelectionのUseTypoMetricsビットを直接判定し、立っていなければface->ascender(FreeTypeがhheaから読み出した値)を採用します。 ↩ -
src/ports/SkScalerContext_win_dw.cpp。SkiaはIDWriteFontFace::GetMetricsの戻り値をそのまま使用しており、usWin*/sTypo*の選択はDirectWrite側で行われます。GDI経由の場合も同様でsrc/ports/SkFontHost_win.cpp、GetOutlineTextMetricsの戻り値を使います。 ↩ -
src/ports/SkScalerContext_mac_ct.cpp。SkiaはCTFontGetAscent/CTFontGetDescent/CTFontGetLeadingの戻り値をそのまま使用し、選択はCoreText側で行われます。 ↩



