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

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

黑色卡片上的白色 Astro 火箭標誌與字樣,火焰為粉紅色
本頁目錄

引言

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

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

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

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

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

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 像素的來源只會產生一個候選:

<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 個。

256px 的螢幕截圖繪製為 662px,與依原始寬度繪製的對照以 662px 的內文欄為基準的兩條橫條。上方橫條標示 width: 100%,延伸至整欄的 662px,標記為放大 2.48 倍、需要 1,324 個裝置像素,但檔案只提供 256 個。下方橫條標示 width: auto,止於 256px,標記為未放大,需要 512 個裝置像素,同樣提供 256 個。內文欄寬 662pxwidth: 100%放大 2.48 倍繪製為 662px需要 1,324 裝置像素,只有 256width: auto未放大繪製為 256px需要 512 裝置像素,有 256檔案寬度是 256px。只有其中一種會向瀏覽器索取它沒有的像素。
同一個檔案在兩條規則下的樣子。長條是繪製寬度,下面那一行才是決定它清不清楚的數字。

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

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

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

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 的話,記錄尺寸的地方就只剩下檔案本身,換掉檔案,版面就跟著換。

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

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

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 個裝置像素。

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

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

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

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

製圖時的 font-size使用次數桌機(0.795 倍)手機(0.405 倍)
9px207.2 CSS 像素3.6 CSS 像素
10px1368.0 CSS 像素4.1 CSS 像素
11px1358.7 CSS 像素4.5 CSS 像素
13px7810.3 CSS 像素5.3 CSS 像素

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

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

同一份圖表的標籤在製圖時、內文欄、手機與對話框中的尺寸對照四張卡片,顯示同一份 800 單位的圖表在四種繪製下的結果。製圖時的基準標籤為 10 與 11 單位。在 662px 的內文欄中倍率為 0.795,繪製為 8.0 與 8.7 CSS 像素。在 324px 的手機欄中倍率為 0.405,降到 4.1 與 4.5 CSS 像素,標記為問題所在。在對話框中以 800px、倍率 1.0 繪製,標籤回到 10 與 11 CSS 像素。製圖時800 單位的 viewBox基準10px 標籤1011px 標籤11CSS 像素內文欄662px×0.79510px 標籤8.011px 標籤8.7CSS 像素手機324px×0.40510px 標籤4.111px 標籤4.5CSS 像素對話框800px×1.00010px 標籤1011px 標籤11CSS 像素圖表本身沒有任何改變,改變的只是裝它的容器。
同一份圖表在被要求呈現的每個尺寸下的樣子。最右邊那張卡片是打開之後會回到的狀態,文章其餘部分講的就是怎麼走到那裡。

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

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

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

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

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

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);
}
對話框依媒體套用的兩條尺寸規則,以及被否決的單一規則兩張卡片。左邊處理屬於向量的圖表:對話框會配合視窗放大,但絕不低於其 800 單位的 viewBox,因此在手機上會從 324px 變成 800px。右邊處理屬於點陣的螢幕截圖:對話框以檔案寬度為上限,若圖比視窗高則以平移檢視,所以 256px 的檔案仍是 256px。下方的橫幅記錄了被否決的單一規則,也就是兩軸都配合視窗,結果讓 256×836 的截圖回傳為 202×661。圖表(向量)配合視窗,下限為 viewBox沒有上限,放大不會失真不低於 800px手機上 324px → 800px螢幕截圖(點陣)以檔案寬度為上限,過高則平移有上限,超過就是補出來的不超過檔案寬度256px → 256px,以捲動檢視兩者共用一條規則:兩軸都配合視窗256×836 的截圖回傳為 202×661,比文章中原本顯示的還小
兩種媒體,兩個上限。下方的橫幅是最先試過的那一條規則,以及它回傳的數字。

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

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

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

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

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

同一張圖的無障礙樹,在觸發元素不再包住它之前與之後兩個面板顯示同一張圖的無障礙樹。左側的觸發元素是包住整張圖的 button,因此樹中只剩下一個節點:button「放大圖表」。帶有 role="img" 與 aria-labelledby 的 svg,以及帶有 alt 的 img,都以淡化的虛線框標示並註明已從樹中被丟棄,因為 button 會將子元素視為 presentational。結果是全部 86 張圖、三種語言都只剩兩個字取代原本的描述。右側的觸發元素是圖旁的標記,因此樹中同時保有承載圖說明的 image 節點,以及名為「放大圖表」的獨立 button 節點。用 button 包住整個圖button「放大圖表」svg role="img" aria-labelledbyimg alt="…"在圖旁邊放一個標記image「側邊欄的目錄…」button「放大圖表」全部 86 張圖、三種語言,都只剩兩個字圖保有自己的描述,控制項也有名稱
淡化的那幾列仍然在 DOM 裡,也仍然有翻譯。只是沒有被公開出來而已。

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

那個不是寬度的寬度

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

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

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

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

數字說了什麼

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

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

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

總結

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

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

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

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

參考連結

分享這篇文章