iOS Safari で PDF 保存すると CSS linear-gradient の罫線が黒塗りに ― SVG + non-scaling-stroke で解決

CSS
B!

先に結論

  • iPhone / iPad の Safari の「印刷 → PDF 保存」は、Apple 製の PDF 生成エンジン Quartz PDFContext を通して書き出される。
  • Quartz PDFContext は CSS linear-gradient で描いた細い罫線を正しくベクタ化できないことがあり、小さいセル (~3mm) の中では「ベタ塗りの黒帯」としてラスタライズされてしまう。
  • デスクトップの Chrome / Safari では同じ CSS がベクタとしてきれいに描画されるため、開発側では再現しない「iPhone / iPad の Safari だけで起きる」バグになりやすい。
  • 対策は、罫線の描画を <svg> + vectorEffect="non-scaling-stroke" に置き換えること。Quartz でも細線がベクタとして保持される。

発端:「PDF が黒塗りで見にくい」というお問い合わせ

弊社の運営するミニバスケットボール (U12) 向けスコアシートアプリ「ミニバスコア」で、ご利用者さまから 「PDF で保存をすると、添付した画像のように黒く塗りつぶされていて見にくくなっています」というご指摘をいただきました。

一方で、運営側 (Mac + Chrome / Safari デスクトップ) で同じ試合データから PDF を保存しても、まったく症状が出ません。画面上のプレビューも問題なし。つまり 「ユーザー環境固有の問題」 であることだけが分かる状態からのスタートでした。

原因特定の決め手は PDF のメタデータだった

最初はコード側の仮説 (罫線の線幅・間隔が狭すぎる等) を立てて修正を試しましたが、運営側では再現しないので改善の確認もできません。方針を切り替えて、ご共有いただいた PDF ファイル自体を解析しました。

$ pdfinfo "ミニバス公式スコア準拠の記録アプリ.pdf"
Title:    ミニバスコア — ミニバス公式スコア準拠の記録アプリ
Creator:  Safari
Producer: iOS Version 27.0.1 (Build 24A446) Quartz PDFContext
...

これで一気に確定しました。iPhone / iPad の Safari で「印刷 → PDF 保存」をされており、PDF の書き出し自体は Apple 製の Quartz PDFContext が担当しているという状況です。

Quartz PDFContext はベクタ描画は得意ですが、CSS で複雑に組んだ背景パターン (linear-gradient) を忠実にベクタ化できないケースがあります。特に、小さなセル (今回のケースでは幅 3mm 前後のチームファウル欄や出場時限欄) に、1px の細い線を 2 本重ねたような gradient があると、Quartz はこれを「複雑な小さい図形」と判断してラスタライズし、不透明の黒帯として PDF に埋め込んでしまうことがあります。

デスクトップの Chrome / Safari ではこの変換パスを通らないため、同じ CSS がきれいにベクタとして PDF に出力されます。自分の環境で再現しないのはそのためで、ユーザー環境のメタデータが無ければ到底気づけない類のバグでした。

修正方針:罫線を SVG に差し替える

公式スコアシートの罫線 (選手の出場時限欄のスタメン斜線、チームファウル欄の未使用マスを示す縦二重線や × 印、ランニングスコアの得点に引く斜線など) は、CSS linear-gradient で描いていました。これを <svg> オーバーレイに差し替えます。

Before: linear-gradient で「縦二重線」を描く

// 中央に 2 本の縦線 (間隔 1px、線幅 1px) を描く背景パターン
const VERTICAL_DOUBLE_LINE_BG =
  'linear-gradient(to right,' +
  'transparent calc(50% - 1.5px),' +
  'currentColor calc(50% - 1.5px),' +
  'currentColor calc(50% - 0.5px),' +
  'transparent calc(50% - 0.5px),' +
  'transparent calc(50% + 0.5px),' +
  'currentColor calc(50% + 0.5px),' +
  'currentColor calc(50% + 1.5px),' +
  'transparent calc(50% + 1.5px))';

// 使う側 (チームファウル欄の未発生 Q)
<span style={{
  width: '100%', height: '100%',
  backgroundImage: VERTICAL_DOUBLE_LINE_BG,
  color: 'black',
}} />

デスクトップでは意図通りの「中央に 2 本縦線」になります。しかし iOS Safari で PDF に書き出すと、この 3-5mm 幅のセルの中では 「ベタ塗りの黒い縦長矩形」としてラスタライズされてしまい、黒塗りに見えます。

After: SVG + vectorEffect=”non-scaling-stroke”

function SheetSvgOverlay({ children }: { children: React.ReactNode }) {
  return (
    <svg
      aria-hidden
      viewBox="0 0 10 10"
      preserveAspectRatio="none"
      style={{
        position: 'absolute',
        inset: 0,
        width: '100%',
        height: '100%',
        pointerEvents: 'none',
      }}
    >
      {children}
    </svg>
  );
}

// 縦二重線 (中央付近に 2 本縦線)
function VerticalDoubleLine({ color = 'currentColor' }: { color?: string }) {
  return (
    <SheetSvgOverlay>
      <line x1="4" y1="0" x2="4" y2="10"
            stroke={color} strokeWidth="0.5"
            vectorEffect="non-scaling-stroke" />
      <line x1="6" y1="0" x2="6" y2="10"
            stroke={color} strokeWidth="0.5"
            vectorEffect="non-scaling-stroke" />
    </SheetSvgOverlay>
  );
}

// 使う側 (relative コンテナ + 絶対配置のオーバーレイ)
<span style={{ width: '100%', height: '100%', position: 'relative' }}>
  <VerticalDoubleLine />
</span>

なぜ vectorEffect=”non-scaling-stroke” を付けるのか

SVG の viewBox は内部座標系で、preserveAspectRatio="none" と組み合わせて 任意のサイズのセルに引き伸ばして 使っています。普通にすると引き伸ばしに合わせて strokeWidth も太くなったり細くなったりしますが、vectorEffect="non-scaling-stroke" を付けると、線の太さが「最終表示上のピクセル単位」で固定されます。

これにより、どれだけ小さいセルに描画されても線幅は変わらず、Quartz PDFContext も「意図された細い線」として PDF にベクタで埋め込みます。結果として、デスクトップでも iOS Safari でも同じ見た目の罫線が描けるようになります。

同じ要領で置き換えた箇所

公式スコアシートの中で linear-gradient で描いていた罫線を、同じ考え方で SVG に差し替えました。

  • VerticalDoubleLine: チームファウル欄の未発生 Q (中央に 2 本縦線)
  • HorizontalDoubleLine: タイムアウト欄の未取得 Q (中央に 2 本横線)
  • CrossMark: チームファウル欄の発生分 (× 印)
  • DiagonalLine (slash / backslash): 出場時限欄のスタメン / 途中出場、ランニングスコアの 2P/3P/OG 得点マスに引く斜線
  • 試合終了マスの二重下線 (直接 SVG で描画)

教訓

  • 環境固有バグはユーザー環境の情報から追う。PDF の pdfinfo メタデータは、ブラウザとエンジン、ひいては OS の推定にまで使える「原因特定の武器」。
  • デスクトップで再現しない = バグが無い、ではない。再現できない場合に「仮説 → コード修正 → DEV 確認」のサイクルを回しても意味がなく、まず「どんな環境で起きているか」を確定させる方が速い。
  • CSS linear-gradient の罫線は、変換環境を選ぶ。印刷 / PDF 保存を前提にしたレイアウトでは、SVG で描く方が互換性のリスクが低い。
  • vectorEffect="non-scaling-stroke" は、サイズ可変の SVG で罫線を使うときの定番オプション。覚えておくと罫線系の UI/印刷コンテンツで役に立つ。

参照リンク

B!
← 一覧へ戻る