10
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Webページで一部フォントの位置がOS依存になる問題に対処する

10
Last updated at Posted at 2025-04-17

Tajawal(投稿時点のコミットハッシュ:2085b89)をVivliostyleで使用していたところ、WindowsとLinux(&macOS)で組版結果が異なることに気づきました。Playwrightを直接使用して確認してみても同様の問題が発生することがわかりました。

Linux Windows
before.linux.png before.win32.png
再現用ソースコード
<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/2usWinAscent/usWinDescenthheaテーブルのascent/descentという3系統で持っています。OS/2.fsSelectionUSE_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-overridedescent-overrideline-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
after.linux.png after.win32.png

完全一致とはいきませんが実用上問題ない程度に改善することができました。

  1. src/ports/SkFontHost_FreeType.cpp(Skia HEAD a87f066)。fsSelectionUseTypoMetricsビットを直接判定し、立っていなければface->ascender(FreeTypeがhheaから読み出した値)を採用します。

  2. src/ports/SkScalerContext_win_dw.cpp。SkiaはIDWriteFontFace::GetMetricsの戻り値をそのまま使用しており、usWin*/sTypo*の選択はDirectWrite側で行われます。GDI経由の場合も同様でsrc/ports/SkFontHost_win.cppGetOutlineTextMetricsの戻り値を使います。

  3. src/ports/SkScalerContext_mac_ct.cpp。SkiaはCTFontGetAscent/CTFontGetDescent/CTFontGetLeadingの戻り値をそのまま使用し、選択はCoreText側で行われます。

10
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?