SVGで作図ツールを作っていると、コードを変えていないのに、Chrome更新後に線が急に霞んで見えることがあります。特にstroke-widthへ0.05のような1px未満の値を指定している場合に起きやすい症状です。
同時に、viewBoxを書き換えてズーム・パンするビューアでは、縮小しすぎて図の外側の余白が見えたり、ドラッグで描画範囲外まで移動できたりする問題も出ます。
この記事では、SVG作図ツールで起きやすい2つの表示問題と、Puppeteerで実際にインストール済みChromeを使って検証する方法をまとめます。
1px未満の線はブラウザ更新で見え方が変わる
SVGのstroke-widthに1px未満の値を指定すると、ブラウザの描画実装によって見え方が変わることがあります。
今回の症状では、以前のChromeでは1デバイスピクセル未満の線幅が、実質的に1pxの不透明な線として描かれていました。現在のChromeでは、指定幅に比例した薄いアルファで描かれるため、0.05pxなら非常に薄く表示され、霞んだように見えます。
| 経路 | 挙動 |
|---|---|
| 画面表示のSVG | 1px未満の線が薄いアルファで描かれ、霞む |
| Canvas経由の画像書き出し | 1pxに切り上がることがあり、くっきり見える |
「画面は汚いのに、書き出したPNGはきれい」という場合、この差を疑います。
線幅はデバイスピクセル基準で下限を設ける
画面の物理ピクセルより細い線は、安定して描けません。線幅はCSSピクセルへ変換し、最低1デバイスピクセルを保証します。
const AppConfig = {
line: {
widthScale: 20,
minDevicePixels: 1
}
};
function resolveStrokeWidth(lineWidth) {
const devicePixelRatio = window.devicePixelRatio || 1;
const cssWidth = lineWidth * AppConfig.line.widthScale;
return Math.max(cssWidth, AppConfig.line.minDevicePixels / devicePixelRatio);
}
path.setAttribute('stroke-width', resolveStrokeWidth(userValue));
path.setAttribute('vector-effect', 'non-scaling-stroke');
vector-effect="non-scaling-stroke"を併用すると、拡大縮小しても線幅が一定に保たれます。線幅スライダーの値も、画面上の見た目に反映されやすくなります。
| 設定値 | 修正前の実効幅 | 修正後の目安 |
|---|---|---|
0.01 | ほぼ不可視 | 1px相当 |
0.05 | 霞む | 1px相当 |
0.10 | 薄い | 2px相当 |
0.20 | 薄い | 4px相当 |
細い印象を出したい場合は、1px未満の線幅に頼るより、1pxのままstroke-opacityや色を調整する方が安定します。
viewBoxのズームは最小倍率を1にする
viewBoxの幅をwidth / zoomScaleにしてズームする方式では、zoomScale < 1にすると図より広い範囲を映すため、必ず外側の余白が見えます。
const zoom = {
minScale: 1,
maxScale: 100
};
最小倍率を1にすれば、縮小方向では全体表示より外へ出ません。ズームアウトボタンは、1未満へ下げるのではなく、1で止めます。
パン位置は描画範囲内へクランプする
拡大中にドラッグできる場合は、表示範囲が描画範囲の外へ出ないようにviewBoxのxとyを丸めます。
表示範囲は[x, x + width / scale]です。これが[0, width]に収まる条件は次の通りです。
0 <= x <= width * (1 - 1 / scale)
function clampViewBox() {
const maxX = Math.max(vb.width * (1 - 1 / zoomScale), 0);
const maxY = Math.max(vb.height * (1 - 1 / zoomScale), 0);
vb.x = Math.min(Math.max(vb.x, 0), maxX);
vb.y = Math.min(Math.max(vb.y, 0), maxY);
}
zoomScale = 1のときmaxX = 0になるため、自然にx = 0へ固定されます。
viewBox更新の全経路で呼ぶ
クランプ処理は、viewBoxを更新する全経路の直前で呼びます。1箇所でも漏れると、そこから枠外へ移動できてしまいます。
setZoom() -> clampViewBox() -> updateViewBox()
pan() -> clampViewBox() -> updateViewBox()
initialize() -> clampViewBox() -> updateViewBox()
特に、パラメータ変更後に拡大位置を維持する実装では、前回位置をそのまま復元すると描画範囲外へ飛ぶことがあります。再描画時も必ずクランプを通します。
Puppeteerはインストール済みChromeを指定する
「Chrome更新後に壊れた」問題をPuppeteerで再現する場合、バンドルChromiumでは再現できないことがあります。Puppeteerはバージョンごとに固定のChromiumを使うため、ユーザーが実際に使っているChromeの挙動とずれるためです。
PCにインストール済みのChromeをexecutablePathで指定します。
const puppeteer = require('puppeteer');
const browser = await puppeteer.launch({
headless: 'new',
executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe',
userDataDir: 'C:/path/to/scratchpad/chrome-profile'
});
userDataDirは一時フォルダにします。普段使いのChromeプロファイルと衝突させないためです。
画面表示と書き出し画像を分けて検証する
Puppeteerでは、属性値、JavaScriptエラー、画面スクリーンショット、書き出し画像をまとめて検証できます。
const state = await page.evaluate(() => {
const curve = document.querySelector('#lissajous-svg polyline');
return {
strokeWidth: curve.getAttribute('stroke-width'),
viewBox: document.getElementById('lissajous-svg').getAttribute('viewBox'),
zoom: document.getElementById('zoomLevel').textContent,
dpr: window.devicePixelRatio
};
});
page.on('pageerror', (error) => errors.push(String(error)));
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
要素単位でスクリーンショットを撮ると、描画エリアだけを比較できます。
const el = await page.$('#svgContainer');
await el.screenshot({ path: 'screen_after.png' });
書き出しボタンを実際に押さず、生成されたdata URLを取り出す検証も有効です。
const dataUrl = await page.evaluate(async () => {
await FileExportManager.prepareDownload('png');
return document.getElementById('downloadLinkPNG').href;
});
fs.writeFileSync('export_after.png', Buffer.from(dataUrl.split(',')[1], 'base64'));
これにより、画面表示とCanvas経由の書き出しで結果が違う問題を、同じテスト内で比較できます。
検証観点
stroke-widthが1デバイスピクセル未満になっていない- 線幅スライダーの値が画面上の見た目に反映される
- ズームアウトしても
zoomScale = 1で止まる - 拡大後に強くドラッグしても
viewBoxが範囲外へ出ない - パラメータ変更後の再描画でも位置が範囲内に収まる
- 実機Chromeで画面表示と書き出し画像を比較できる
- JavaScriptエラーが出ていない
まとめ
SVG作図ツールでは、1px未満の線幅とviewBox操作が表示崩れの原因になりやすいです。
安定させるポイントは次の通りです。
- 線幅はデバイスピクセル基準で下限を設ける
- 細さは線幅だけでなく透明度や色で調整する
- ズームの最小倍率は1にする
- パン位置は描画範囲内にクランプする
viewBox更新の全経路でクランプする- Chrome更新起因の不具合は、インストール済みChromeをPuppeteerへ指定して検証する
ブラウザ更新で見え方が変わった場合は、コードの論理だけでなく、実際の描画エンジンで再現確認することが重要です。
