# 讓 Astro 的圖表依自己的尺寸繪製、點一下就能放大 — 原始寬度、原生對話框與 @2x

> 在 Astro 中 width:100% 為何讓 256px 的螢幕截圖變糊，以及原生對話框如何讓每張圖表在手機上都讀得清楚。修正這個設計的是七次計測。

- Source: https://oharu121.com/zh-tw/blog/astro-figure-intrinsic-width-native-dialog-2x-density/
- Published: 2026-08-15T09:17:03+09:00
- Tags: Astro, CSS, Web API

---
## 引言

我把這個網站自己的目錄側邊欄截圖放進一篇文章裡，結果看起來糟透了。側邊欄本來是一條窄長的 UI，但在文章中被撐到整個內文欄的寬度，糊到裡面的文字都在暈開，高到把其他內容全都擠出畫面。看著看著，我也注意到問題的另外一半：沒有任何辦法把圖表放大。讀者想看仔細一點，唯一能做的就是用雙指縮放整個頁面。

那張截圖已經上線整整一個版本，沒有任何人反映過。截圖本身也沒有問題。**造成模糊的，是一條套用在全站每一張圖表上的 CSS 宣告**，而只有窄的那些會真的受害。

修掉模糊，最後是比較小的那一半。更大的發現在同一個元件的另一側：**那些從來沒被拉伸過、因此看起來一切正常的圖表，才是沒有人讀得了的。**本文會談這兩件事，以及過程中七個看起來明顯正確、一量就發現錯了的設計決定。

## 一條宣告把每張圖表都撐到整欄寬

這個網站的圖表全部都會經過同一個元件，而那個元件對它包住的東西只有一條規則：

```css title="src/components/article/Figure.astro"
.frame :global(svg),
.frame :global(img) {
	display: block;
	width: 100%;
	height: auto;
}
```

對站上大多數內容來說，這條規則是看不出來的。插圖寬度介於 1,100 到 1,774 像素之間，而內文欄是 662 像素，所以**這條規則永遠只會把它們縮小，而縮小不需要任何代價**。那張側邊欄截圖是**寬 256 像素、高 836 像素**，於是同一條規則把它放大了 2.48 倍，變成 662 × 2,161。

有意思的是瀏覽器手上到底有什麼。Astro 會依原始檔案產生 `srcset`，而一個 256 像素的來源只會產生一個候選：

```html
<img src="/_astro/sidebar-rail.DjnEbD0x_Z25K3Dc.webp"
     srcset="/_astro/sidebar-rail.DjnEbD0x_Z25K3Dc.webp 256w"
     sizes="(min-width: 256px) 256px, 100vw"
     width="256" height="836">
```

**也就是說，瀏覽器下載了 256 像素的圖，被 CSS 要求去填滿 662 像素的版面，而且沒有更大的檔案可以抓。**我當時用的筆電裝置像素比是 2，所以真正的需求是 1,324 個裝置像素，而拿來填的實際資料只有 256 個。

*Figure — StretchedRaster: 同一個檔案在兩條規則下的樣子。長條是繪製寬度，下面那一行才是決定它清不清楚的數字。*

**這個倉庫裡沒有任何東西在盯著這件事。**跨語系檢查確認的是：英文版引用到的圖表，日文版與繁體中文版也有引用，但它從不看檔案本身。圖表適配檢查會在瀏覽器中繪製每張圖表、量測標籤有沒有超出方框，但它只量 SVG 元件，因為帶有翻譯文字的是那一類。**點陣圖沒有屬於自己的檢查，所以擋在一張被拉伸的截圖與正式站之間的，只有「有沒有人剛好發現」。**

## 讓尺寸被推導出來，而不是被宣告

修正比錯誤本身還小。Astro 會為它處理過的每張圖片輸出 `width` 與 `height` 屬性，因為它在建置時就知道檔案的實際尺寸。把 CSS 的寬度設成 `auto`，瀏覽器就會採用那個數字：

```css title="src/components/article/Figure.astro"
.frame :global(img) {
	display: block;
	margin-inline: auto;
	width: auto;
	max-width: 100%;
	max-height: 56rem;
	height: auto;
}
```

另一個做法是在元件上加一個 `width` 屬性，這個我否決了。這個部落格每篇文章一個資料夾，裡面放三個語系檔案，所以寫在標記裡的寬度會是一個要寫三次、還得靠人工跟素材保持同步的數字，而一致性檢查也得為它多一條規則。用 `width: auto` 的話，**記錄尺寸的地方就只剩下檔案本身**，換掉檔案，版面就跟著換。

第一個出錯的是高度上限。智能體一開始給的值是 `min(48rem, 85svh)`，理由是圖表不該比視窗還高。在一個高 724 像素的瀏覽器視窗裡量測，那條規則把寬 256 的截圖壓成了 **188 × 615**，等於把一張才剛修好「被重新取樣」問題的圖，又重新取樣了一次。**一個跟著可視區域走的上限，會把「絕不放大」變回「一律縮放」。**現在的值是固定的 `56rem`，剛好放行站上最高的 836 像素圖表，同時仍然擋得住異常的輸出。

最後一點是外觀問題。一張 224 像素寬的截圖被擺在 662 像素的帶框方塊正中央，本身看起來就像個錯誤，所以外框會依點陣圖縮到剛好。元件是透過插槽接收內容的，在伺服器端無法判斷它包住的是照片還是圖表，因此這個判斷交給 CSS：

```css title="src/components/article/Figure.astro"
.figure:has(.frame img) .frame {
	width: fit-content;
	max-width: 100%;
	margin-inline: auto;
}
```

## `@2x`，因為 `width: auto` 分不出「密」和「大」

依原始寬度決定尺寸之後，還有一件事沒解決。**一個打算以 2 倍密度顯示成 224 像素的 448 像素檔案，在 CSS 眼中，和一張想要顯示成 448 像素的 448 像素圖片完全一樣。**訊號必須來自別的地方，而最省事的地方就是檔名：命名為 `<name>@2x.webp` 的點陣圖，是以預期顯示尺寸的兩倍存放的，元件則用 `zoom: 0.5` 把它折半。

這條規則裡有兩件事是靠量測而不是靠推理找出來的，而且兩件都是無聲的。

選擇器需要比對兩種寫法。`astro build` 會把檔名主幹原樣寫進資產名稱，產生 `/_astro/sidebar-rail@2x.HASH.webp`；但開發伺服器不是這樣，它把圖片路由到一個以路徑當查詢參數的端點，其中**`@` 會被百分比編碼成 `%402x`**。只比對原始寫法的規則，在正式站可以運作，在本機則完全沒有作用，這是所有組合裡最糟的一種。一開始就是那樣寫的，而**它之所以被抓到，是因為瀏覽器上的數字沒有變。**

這兩個上限在 `zoom` 之下的行為並不相同，這也是其中一個被加倍、另一個沒有的原因：

| 宣告 | 實際生效的位置 |
| --- | --- |
| `max-height: 56rem` | 螢幕上的 448 像素。絕對長度是以該元素自己被折半後的像素來計算，所以上限落在字面值的一半。 |
| `max-width: 100%` | 外框的實際寬度。百分比是相對於包含區塊解析的，而包含區塊沒有被 zoom，所以它們本來就是字面上的意思。 |

第一次嘗試把兩個都加倍了。**它們的行為並不一致，代價是圖片從被壓窄的外框裡溢出了 70 像素。**規則能動之後，我要求把原本那張截圖用兩倍解析度重拍。現在它是 448 × 1,672，繪製為 224 × 836，並以 448 個實際像素填滿所需的 448 個裝置像素。

## 看起來正常的圖表，才是讀不了的那些

以上就是尺寸的部分，而真正受害嚴重的檔案剛好只有一個。另外一半的工作，是從智能體提出「只在圖表確實被縮小時才顯示放大操作」開始的，理由是對一張已經是原尺寸的圖提供放大，等於沒有給讀者任何東西。

我否決了這個提議。**一張圖表可以是原尺寸，同時裡面的文字小到讀不了**，而這一點套在圖表上至少和套在螢幕截圖上一樣成立。這裡值得把定案的算式寫出來，因為文章其餘部分都建立在這個數字上。

這個倉庫裡的每張圖表都是在 800 單位的 `viewBox` 上製作的，並繪製進和其他所有東西相同的 662 像素外框。桌機上的倍率是 0.795。在 390 像素的手機上，外框寬度是 324 像素，倍率是 0.405。把全站實際用到的標籤尺寸帶進去：

| 製圖時的 `font-size` | 使用次數 | 桌機（0.795 倍） | 手機（0.405 倍） |
| --- | --- | --- | --- |
| `9px` | 20 | 7.2 CSS 像素 | **3.6 CSS 像素** |
| `10px` | 136 | 8.0 CSS 像素 | **4.1 CSS 像素** |
| `11px` | 135 | 8.7 CSS 像素 | **4.5 CSS 像素** |
| `13px` | 78 | 10.3 CSS 像素 | **5.3 CSS 像素** |

圖表是這個網站上做得最仔細的東西，而在手機上它們的標籤只有三點五到五像素高。**一個以「這張是不是被縮小了」為條件的判斷，會剛好把放大操作從它們身上藏起來，因為圖表從來不會被縮小。**圖表是被等比縮放，而讓它讀不了的正是那個縮放。

於是放大行為變成了全面適用：每張圖表都能開啟，點陣圖和圖表一樣，在任何可視區域都一樣。**正是這個決定，把一張模糊截圖的修正，變成了影響全站 86 張圖表、三種語言的變更。**

*Figure — ScaleCollapse: 同一份圖表在被要求呈現的每個尺寸下的樣子。最右邊那張卡片是打開之後會回到的狀態，文章其餘部分講的就是怎麼走到那裡。*

## 對話框需要的是兩條規則，不是一條

這個模式站上本來就有。搜尋視窗是一個用 `showModal()` 開啟的原生 `<dialog>`，焦點處理、Escape 與 `::backdrop` 都不必自己寫。而且它有一個大多數圖片燈箱沒有的性質：因為它位在瀏覽器的頂層，而不是一個定位過的覆蓋層，**雙指縮放在它裡面仍然有效。**這比字面上聽起來更重要，因為這是「上限固定的放大鏡」和「讀者可以一直推下去的放大鏡」之間的差別。

第一版把圖表在兩個軸向上都塞進視窗，這就是燈箱在做的事。打開那張 256 × 836 的截圖，回傳的是 **202 × 661**，比它在文章裡已經有的 256 × 836 還小。**把直式的圖塞進橫式的視窗，本身就是一種縮小**，於是第一個放大鏡把東西變小了。

第二版對點陣圖修好了這件事，對圖表卻仍然是錯的。點陣圖改成以檔案實際寬度為上限，比視窗高就捲動，這是對的。圖表沒有可以停下來的原始尺寸，於是被塞進視窗，而在一個寬 500 像素的瀏覽器上，倍率是 **1.03 倍**。圖表本來就填滿了整欄，把它塞進視窗完全沒有任何收穫。對話框重現了它存在的理由。

**兩次嘗試都漏掉的是：這兩種媒體要的東西剛好相反。**點陣圖的像素數量是固定的，超過之後放大出來的細節都是補的。圖表是向量，沒有上限，卻有一個有意義的下限：**它被製作時所在的 `viewBox`。**低於 800 單位，它的標籤就會回到當初在文章裡讀不了的那個縮小版本。所以規則被拆開了：

```css title="src/components/article/Figure.astro"
/* A diagram fits the window, but never draws below the grid it was authored on. */
.figzoom-frame[data-figzoom-media='vector'] .figzoom-stage {
	min-width: calc(var(--figzoom-vbw, 800) * 1px);
}

/* A raster shows every pixel it has, and pans if it is taller than the window. */
.figzoom-frame[data-figzoom-media='raster'] {
	width: min(var(--figzoom-cap, 95vw), 95vw);
}
```

*Figure — DialogRules: 兩種媒體，兩個上限。下方的橫幅是最先試過的那一條規則，以及它回傳的數字。*

在一單位對一像素的情況下，`font-size: 10` 的標籤會繪製成 10 CSS 像素，而外框改以平移取代縮小，這和文章本來就為最寬的那些圖表所做的取捨是同一個。在建置好的網站上量測，圖表在桌機上從行內的 662 像素變成**對話框中的 1,342 像素，在手機上則從 324 變成 800**。在文章裡只有 4.5 CSS 像素高的 `font-size: 11` 標籤，在對話框中是 **11 CSS 像素**，而且讀者再用雙指放大下去，也不會變糊。

## 那個抹掉每張圖表說明的 button

到這個時候，這份工作已經通過了型別檢查、跨語系檢查、正式建置與圖表適配量測。接著對這個分支跑了一次審查，找出兩個缺陷，而兩個都不是建置會抓到的那一類。

第一個出在智能體為觸發元素挑的標記上。用 `<button>` 把整張圖表包起來，是讓它可點擊最自然的做法，而它的破壞是安靜的：**ARIA 規定 button 的子元素屬於 presentational。**裡面的一切都會從無障礙樹上被移除，而那棵樹是螢幕閱讀器唯一擁有的東西。

這裡被移除的並不是裝飾。每張圖表都帶有 `role="img"`，以及指向標題與說明的 `aria-labelledby`，每張點陣圖也都帶有 `alt`。這些是翻譯流程以三種語言維護的字串，而且往往是一張圖表上最長、寫得最仔細的文字。被 button 包起來之後，這些全部消失，**螢幕閱讀器剩下的只有 button 上的兩個字**，全站每一張圖表都是如此。

*Figure — A11yTree: 淡化的那幾列仍然在 DOM 裡，也仍然有翻譯。只是沒有被公開出來而已。*

修法是不要再包。觸發元素現在是一個放在外框內、圖表旁邊的放大鏡標記，而不是包住圖表的 button，這樣圖片保有自己的語意，標記則負責名稱與彈出提示。使用指標裝置的讀者點外框任何位置仍然會開啟對話框，但沒有任何東西依賴那條路徑。事後再把無障礙樹讀回來，每張圖表都會回報一個帶有完整說明的 image 節點，**以及**一個獨立的 `button「放大圖表」`。這本來就該是這個樣子。

## 那個不是寬度的寬度

第二個缺陷，是一個已經看過、而且被解釋掉的數字。

對話框依點陣圖的實際寬度決定尺寸，而程式碼讀的是 `naturalWidth`。在響應式圖片上，**這個屬性是經過密度校正的**：對 Astro 輸出的寬度描述子而言，密度等於候選寬度除以 `sizes` 解析出來的結果，所以它回傳的是版面寬度，而不是檔案寬度。在 390 像素的可視區域量測，那個 448 像素的檔案回報 `naturalWidth: 389`。

這把上限壓到差不多等於圖表原本的大小，**而且正好在這個功能存在理由的那些窄螢幕上，把放大關得最徹底。**正確的值是 `width` 屬性，它本來就以備援的身分寫在程式碼裡，兩者只是順序反了。

比較難受的是，那個 389 在更早的一次量測中就出現過，而智能體把它當成開發伺服器的怪癖給打發了。它不是。**一個被解釋掉的量測，價值和從來沒量過一樣少。**

## 數字說了什麼

以下每個數字都是對正式建置量測的，而不是開發伺服器，因為開發用的圖片端點會提供不同的候選，給出看似肯定卻錯誤的讀數。

| | 修改前 | 修改後 |
| --- | --- | --- |
| 截圖繪製尺寸 | 662 × 2,161 | 224 × 836 |
| 需要的裝置像素 | 1,324 | 448 |
| 可用的裝置像素 | 256 | 448 |
| 圖表・桌機 | 662px | 對話框中 1,342px |
| 圖表・手機 | 324px | 對話框中 800px |
| `font-size: 11` 標籤・手機 | 4.5 CSS 像素 | 11 CSS 像素 |

**那張截圖從只填滿被要求像素數的五分之一，變成全部填滿。**在手機上圖表會放大 2.47 倍，而且因為它們是向量本身、不是向量的照片，**讀者再往上用雙指放大多少倍，它都維持清晰。**

## 總結

**原本的錯誤是一條宣告：它對我手上剛好有的每一張圖表都是對的，對第一張不符合那個前提的圖表則是錯的。**把它換成 `width: auto` 之後，決定的位置移到了檔案上，改動方式從編輯三個語系檔案，變成換掉一個東西。

更大的發現在同一個元件的另一側，而它之所以被看見，只是因為第一個提案被否決了。**八十六張圖表一直帶著手機上四像素左右高的標籤在線上，而它們沒有一張被視為問題，因為它們什麼都沒變，也沒有任何東西在量它們。**一張模糊的截圖會自己站出來，一張只是太小的圖表不會。

值得留下的既不是前一個修正，也不是後一個。而是**這份工作裡有七個各自獨立的設計決定，都合理、都站得住腳，而且都是錯的，並且每一個都是靠標上一個數字被抓出來的，不是靠想得更透徹。**跟著可視區域走的高度上限、兩軸都塞進視窗的對話框、只大了百分之三的圖表、在本機完全沒作用的選擇器、被當成行為一致的兩個上限、抹掉無障礙樹的 button，以及回傳錯誤寬度的屬性。它們看起來都不像是錯的。尤其那個高度上限，一路讀起來都明顯正確，直到瀏覽器回答 188 為止。

一次全綠的建置一個都沒報出來。型別檢查、跨語系一致性、正式建置與圖表適配檢查全部回報無誤，而在那同時，無障礙樹是空的，放大鏡回傳的圖片比它要放大的還小。

## 參考連結

- [MDN: the dialog element, including showModal and the top layer](https://developer.mozilla.org/zh-TW/docs/Web/HTML/Reference/Elements/dialog)
- [WAI-ARIA 1.2 on presentational children, which is why a button around a figure hides it](https://www.w3.org/TR/wai-aria-1.2/#childrenArePresentational)
- [HTML Standard: naturalWidth, defined as the density-corrected intrinsic width](https://html.spec.whatwg.org/multipage/embedded-content.html#dom-img-naturalwidth)
- [Astro images, covering the width and height attributes emitted at build time](https://docs.astro.build/en/guides/images/)
- [CSS Viewport Module: the zoom property](https://drafts.csswg.org/css-viewport/#zoom-property)
