diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,7 @@
+# Changelog for `hgg-latex`
+
+## 0.1.0.0 — 2026-07-18
+
+First public release on Hackage.
+
+- LaTeX (TikZ) backend: `saveTeX`.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+BSD 3-Clause License
+
+Copyright (c) 2026, Toshiaki Honda
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice,
+   this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+   contributors may be used to endorse or promote products derived from this
+   software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
diff --git a/hgg-latex.cabal b/hgg-latex.cabal
new file mode 100644
--- /dev/null
+++ b/hgg-latex.cabal
@@ -0,0 +1,48 @@
+cabal-version:      3.0
+name:               hgg-latex
+version:            0.1.0.0
+extra-doc-files:    CHANGELOG.md
+synopsis:           LaTeX (TikZ) backend for hgg
+description:
+  LaTeX (TikZ) backend for hgg. Interprets the [Primitive] list from
+  hgg-core into plain TikZ commands, in the same single-pass architecture
+  as the SVG / PDF backends. Adds zero runtime dependencies (pure text
+  generation). Typesetting the generated .tex is left to the user's LaTeX
+  environment (pdflatex, lualatex, ...) — this package does not depend on
+  any LaTeX binary.
+license:            BSD-3-Clause
+homepage:           https://github.com/frenzieddoll/hgg
+license-file:       LICENSE
+author:             Toshiaki Honda
+maintainer:         frenzieddoll@gmail.com
+copyright:          2026 Aelysce Project (Toshiaki Honda)
+category:           Graphics
+build-type:         Simple
+extra-source-files: test/golden/*.tex
+
+common warnings
+  ghc-options:        -Wall -Wcompat -Widentities -Wincomplete-record-updates
+                      -Wincomplete-uni-patterns -Wpartial-fields
+                      -Wredundant-constraints
+
+library
+  import:           warnings
+  exposed-modules:  Graphics.Hgg.Backend.LaTeX
+  hs-source-dirs:   src
+  build-depends:    base               >= 4.17 && < 5
+                  , text               >= 2.0  && < 2.2
+                  , hgg-core  ^>= 0.1
+                  , hgg-frame ^>= 0.1
+  default-language: Haskell2010
+
+test-suite hgg-latex-tests
+  import:           warnings
+  type:             exitcode-stdio-1.0
+  main-is:          Spec.hs
+  hs-source-dirs:   test
+  default-language: Haskell2010
+  build-depends:    base
+                  , hspec              >= 2.10 && < 2.12
+                  , text
+                  , hgg-core
+                  , hgg-latex
diff --git a/src/Graphics/Hgg/Backend/LaTeX.hs b/src/Graphics/Hgg/Backend/LaTeX.hs
new file mode 100644
--- /dev/null
+++ b/src/Graphics/Hgg/Backend/LaTeX.hs
@@ -0,0 +1,463 @@
+-- |
+-- Module      : Graphics.Hgg.Backend.LaTeX
+-- Description : LaTeX (TikZ) backend
+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
+-- License     : BSD-3-Clause
+--
+-- plot-core の '[Primitive]' を TikZ 命令の純テキストに解釈する
+-- (SVG/PDF backend と同じ Layer 1 構図)。 座標系は per-primitive で
+-- y 反転 (TikZ = 左下原点 y 上向き / Primitive = SVG 系 左上原点 y 下向き。
+-- PDF backend と同方針 = global flip は text が鏡像になるため不可)。
+--
+-- emit 規約は A1 実測で確定 (design/phase54-latex/README.md):
+--   * 座標・寸法は明示 bp (= PostScript pt = core の pt。 TeX pt とは 0.37% 差)
+--   * 数値は固定小数 3 桁 (TikZ は指数表記の寸法を読めない)
+--   * 色は preamble に \definecolor{pcRRGGBB}{HTML}{RRGGBB} で集約
+--   * text は node anchor (base west/base/base east) + rotate CCW 恒等
+--   * 既定 preamble は DejaVuSans.sty (core の charWidthEm 較正元と同フォント、
+--     A1 実測で見積り比 0.93-1.11 = SVG backend と同等誤差クラス)
+{-# LANGUAGE OverloadedStrings #-}
+module Graphics.Hgg.Backend.LaTeX
+  ( -- * 通常 (Resolver 不要 = inline 列のみの図)
+    saveTeX
+  , renderTeX
+    -- * Resolver 同伴 (= 'ColByName' を含む図)
+  , saveTeXWith
+  , renderTeXWith
+    -- * 出力設定 (standalone / 素片 mode・preamble 差し替え・CJK family)
+  , saveTeXConfigured
+  , renderTeXConfigured
+  , TeXConfig (..)
+  , CJKMode (..)
+  , defaultTeXConfig
+  , luaLaTeXConfig
+    -- * Phase 14 系: BoundPlot (df バインド済) を描画する
+  , saveTeXBound
+    -- * 低レベル: [Primitive] を直接 TikZ 化 (3D backend glue 用)
+  , savePrimitivesTeX
+  , renderPrimitivesTeX
+  ) where
+
+import           Graphics.Hgg.Frame    (BoundPlot (..))
+import           Graphics.Hgg.Layout   (Layout (..), Rect (..),
+                                        ViewportSize (..), computeLayout)
+import           Graphics.Hgg.Render   (FillStyle (..), LineStyle (..),
+                                        PathSegment (..), Point (..),
+                                        Primitive (..), StrokeStyle (..),
+                                        TextAnchor (..), TextStyle (..),
+                                        Transform (..), renderToPrimitives)
+import           Graphics.Hgg.Spec     (Resolver, VisualSpec, emptyResolver)
+import           Graphics.Hgg.Validate (Severity (..), diagnosticSeverity,
+                                        renderDiagnostic)
+import           Data.Char             (isHexDigit, toUpper)
+import           Data.List             (nub)
+import           Data.Text             (Text)
+import qualified Data.Text             as T
+import qualified Data.Text.IO          as TIO
+import           Numeric               (showFFloat)
+import           System.IO             (hPutStrLn, stderr)
+
+-- ===========================================================================
+-- 入口 (SVG backend の saveSVG / saveSVGWith / saveSVGBound と対称)
+-- ===========================================================================
+
+-- | LaTeX (standalone documentclass) ファイルに保存。 Resolver 不要
+-- (= inline 列のみの図、 = 通常)。 列名参照を含む図は 'saveTeXWith'、
+-- DataFrame は 'saveTeXBound' (@df |>> spec@)。
+saveTeX :: FilePath -> VisualSpec -> IO ()
+saveTeX path = saveTeXWith path emptyResolver
+
+-- | 'Resolver' を渡して LaTeX ファイルに保存。 'ColByName' を含む図用。
+saveTeXWith :: FilePath -> Resolver -> VisualSpec -> IO ()
+saveTeXWith = saveTeXConfigured defaultTeXConfig
+
+-- | 'VisualSpec' を LaTeX text に。 Resolver 不要版。
+renderTeX :: VisualSpec -> Text
+renderTeX = renderTeXWith emptyResolver
+
+-- | 'Resolver' を渡して 'VisualSpec' を LaTeX text に。
+renderTeXWith :: Resolver -> VisualSpec -> Text
+renderTeXWith = renderTeXConfigured defaultTeXConfig
+
+-- ===========================================================================
+-- 出力設定 (rasterific の PNGConfig / savePNGConfigured と対称)
+-- ===========================================================================
+
+-- | LaTeX 出力設定。 'defaultTeXConfig' から record update で部分指定する。
+data TeXConfig = TeXConfig
+  { texStandalone    :: Bool
+    -- ^ True (既定) = standalone documentclass の単体コンパイル可能文書。
+    --   False = @tikzpicture@ 環境の素片のみ (本文へ @\\input@ する用。
+    --   親文書側に tikz / DejaVuSans / (日本語時) CJKutf8 の \\usepackage が必要)
+  , texExtraPreamble :: [Text]
+    -- ^ preamble 追加行 (font 差し替え等)。 standalone 時のみ有効
+  , texCJKMode       :: CJKMode
+    -- ^ CJK ラベルの扱い (既定 'CJKWrap' = pdflatex 向け)
+  , texCJKFamily     :: Text
+    -- ^ 'CJKWrap' 時の CJKutf8 font family (既定 "ipxg" = IPAexゴシック。
+    --   明朝 = "ipxm")。 ラベルに CJK 文字がある時だけ CJK 環境と
+    --   \\usepackage{CJKutf8} を出す
+  }
+
+-- | CJK ラベルの出力方式。 生成 .tex を組版するエンジンに合わせて選ぶ。
+data CJKMode
+  = CJKWrap
+    -- ^ CJK 文字を含むラベルを @\\begin{CJK}{UTF8}{family}@ で包み、
+    --   \\usepackage{CJKutf8} を自動付与 (**pdflatex 向け**、 動作実測済)
+  | CJKRaw
+    -- ^ 包まず生 UTF-8 のまま出す (**lualatex / xelatex 向け**。 CJK フォント
+    --   解決は preamble 側 = luatexja / fontspec 等に委ねる。 lualatex +
+    --   luatexja で組版実測済 = 'luaLaTeXConfig')。
+    --   ★逆組合せの footgun (2026-07-09 実測): 'CJKWrap' の .tex を lualatex
+    --   で組むと **エラーにならず CJK 文字だけ黙って脱落**する。 lualatex で
+    --   組むなら必ず CJKRaw を使うこと
+  deriving (Show, Eq)
+
+-- | 既定設定 (standalone + pdflatex 向け CJKWrap + IPAexゴシック)。
+defaultTeXConfig :: TeXConfig
+defaultTeXConfig = TeXConfig
+  { texStandalone    = True
+  , texExtraPreamble = []
+  , texCJKMode       = CJKWrap
+  , texCJKFamily     = "ipxg"
+  }
+
+-- | lualatex 向け preset (組版実測済 2026-07-09): CJK は wrap せず生 UTF-8、
+-- 日本語フォントは luatexja (既定 = 原ノ味) に委ねる。
+-- @saveTeXConfigured luaLaTeXConfig path r spec@ → @lualatex path@。
+luaLaTeXConfig :: TeXConfig
+luaLaTeXConfig = defaultTeXConfig
+  { texCJKMode       = CJKRaw
+  , texExtraPreamble = ["\\usepackage{luatexja}"]
+  }
+
+-- | 設定付き保存 ('savePNGConfigured' と対称)。
+saveTeXConfigured :: TeXConfig -> FilePath -> Resolver -> VisualSpec -> IO ()
+saveTeXConfigured cfg path r spec =
+  TIO.writeFile path (renderTeXConfigured cfg r spec)
+
+-- | 設定付き render。
+-- ★ PDF backend と同じく pt 直結 (k=1、 dpi 乗算なし)。 layout/prims の
+-- 純 pt を bp 単位でそのまま書く (Phase 33 B5 の唯一 dpi 適用点は raster 系のみ)。
+renderTeXConfigured :: TeXConfig -> Resolver -> VisualSpec -> Text
+renderTeXConfigured cfg r spec =
+  let layout = computeLayout r spec
+      prims  = renderToPrimitives r layout spec
+      ViewportSize w h = lpViewport layout
+  in renderPrimitivesTeXConfigured cfg w h prims
+
+-- | 'BoundPlot' (= @df |>> spec@ の結果) を LaTeX ファイルに保存。
+-- Error severity の検証診断は stderr に報告してから書き出す
+-- (saveSVGBound と同じ lenient 既定)。
+saveTeXBound :: FilePath -> BoundPlot -> IO ()
+saveTeXBound path (BoundPlot r spec diags) = do
+  mapM_ (hPutStrLn stderr . T.unpack . renderDiagnostic)
+        (filter ((== SevError) . diagnosticSeverity) diags)
+  saveTeXWith path r spec
+
+-- | [Primitive] 列を所与のキャンバスサイズで LaTeX に直接書く低レベル経路。
+savePrimitivesTeX :: FilePath -> Int -> Int -> [Primitive] -> IO ()
+savePrimitivesTeX path w h prims =
+  TIO.writeFile path (renderPrimitivesTeX w h prims)
+
+-- ===========================================================================
+-- 文書骨格
+-- ===========================================================================
+
+-- | [Primitive] → standalone LaTeX 文書 (純関数、 golden test 可)。 既定設定。
+renderPrimitivesTeX :: Int -> Int -> [Primitive] -> Text
+renderPrimitivesTeX = renderPrimitivesTeXConfigured defaultTeXConfig
+
+-- | [Primitive] → LaTeX text (設定付き・純関数)。
+renderPrimitivesTeXConfigured :: TeXConfig -> Int -> Int -> [Primitive] -> Text
+renderPrimitivesTeXConfigured cfg w h prims = T.unlines $
+  docHead ++ body ++ docFoot
+  where
+    hasCJK = texCJKMode cfg == CJKWrap && any primHasCJK prims
+    primHasCJK (PText _ s _) = T.any (> '\xFF') s
+    primHasCJK _             = False
+    docHead
+      | texStandalone cfg =
+          [ "% Generated by hgg-latex"
+          , "\\documentclass[border=0pt]{standalone}"
+          , "\\usepackage[T1]{fontenc}"
+          , "\\usepackage{DejaVuSans}"
+          , "\\usepackage{tikz}"
+          ]
+          ++ [ "\\usepackage{CJKutf8}" | hasCJK ]
+          ++ texExtraPreamble cfg
+          ++ colorDefs prims
+          ++ [ "\\begin{document}" ]
+      | otherwise =
+          -- 素片 mode: 親文書に tikz / DejaVuSans / (CJK 時) CJKutf8 が必要
+          [ "% Generated by hgg-latex (fragment mode)"
+          , "% 要 preamble: \\usepackage{tikz}, \\usepackage{DejaVuSans}"
+            <> (if hasCJK then ", \\usepackage{CJKutf8}" else "")
+          ]
+          ++ colorDefs prims
+    body =
+      [ "\\begin{tikzpicture}"
+        -- viewport を bounding box として固定 (primitive がキャンバス全域に
+        -- 届かなくても図サイズを SVG viewport と一致させる)
+      , "\\path[use as bounding box] (0bp,0bp) rectangle ("
+          <> bp (fromIntegral w) <> "," <> bp (fromIntegral h) <> ");"
+      ]
+      ++ drawPrims cfg (fromIntegral h) prims ++
+      [ "\\end{tikzpicture}" ]
+    docFoot
+      | texStandalone cfg = [ "\\end{document}" ]
+      | otherwise         = []
+
+-- ===========================================================================
+-- 色 (theme 色は全て "#rrggbb" hex。 preamble に \definecolor で集約)
+-- ===========================================================================
+
+-- | primitive 列が使う色を重複排除して \definecolor 行に。
+colorDefs :: [Primitive] -> [Text]
+colorDefs prims =
+  [ "\\definecolor{pc" <> hx <> "}{HTML}{" <> hx <> "}"
+  | hx <- nub (concatMap colorsOf prims) ]
+  where
+    colorsOf p = case p of
+      PLine _ _ (LineStyle c _ _) -> [hex6 c]
+      PRect _ fs ms               -> fillC fs ++ strokeC ms
+      PCircle _ _ fs ms _         -> fillC fs ++ strokeC ms
+      PPath _ fs ms               -> fillC fs ++ strokeC ms
+      PText _ _ ts                -> [hex6 (tsColor ts)]
+      _                           -> []
+    fillC (FillStyle c _) = [hex6 c | c /= "none"]
+    strokeC Nothing                   = []
+    strokeC (Just (StrokeStyle c _))  = [hex6 c]
+
+-- | TikZ 色名 (pcRRGGBB) 参照。
+colorRef :: Text -> Text
+colorRef c = "pc" <> hex6 c
+
+-- | 色文字列 → 6 桁大文字 hex。 "#rgb" は倍長化、 named / 不正は
+-- PDF backend の colorOf と同じ fallback (white 以外は黒)。
+hex6 :: Text -> Text
+hex6 t = case T.unpack t of
+  ['#', r1, r2, g1, g2, b1, b2]
+    | all isHexDigit [r1, r2, g1, g2, b1, b2] ->
+        T.pack (map toUpper [r1, r2, g1, g2, b1, b2])
+  ['#', r, g, b]
+    | all isHexDigit [r, g, b] -> T.pack (map toUpper [r, r, g, g, b, b])
+  "white" -> "FFFFFF"
+  _       -> "000000"
+
+-- ===========================================================================
+-- Primitive 解釈器
+-- ===========================================================================
+
+-- | Primitive 列を順に TikZ 行へ。 第 1 引数 = viewport 高さ (y 反転用)。
+--
+-- PClipPush/PTransformPush は対応する Pop までを**再帰グルーピング**して
+-- @\\begin{scope}@ に入れる (PDF backend の 'breakMatch' と同型。 TikZ の
+-- clip / cm も scope 終端でしか戻せない)。 対応の取れない Pop は黙って無視。
+drawPrims :: TeXConfig -> Double -> [Primitive] -> [Text]
+drawPrims cfg h = go
+  where
+    go [] = []
+    go (PClipPush rect : rest) =
+      let (inner, after) = breakMatch isClipPush isClipPop rest
+      in ("\\begin{scope}" : clipRectOf h rect : go inner)
+         ++ ("\\end{scope}" : go after)
+    go (PTransformPush tr : rest) =
+      let (inner, after) = breakMatch isTrPush isTrPop rest
+      in ("\\begin{scope}[cm={" <> cmOf h tr <> "}]" : go inner)
+         ++ ("\\end{scope}" : go after)
+    go (PClipPop : rest)      = go rest
+    go (PTransformPop : rest) = go rest
+    go (p : rest)             = drawOne cfg h p ++ go rest
+
+    isClipPush p = case p of { PClipPush _ -> True; _ -> False }
+    isClipPop  p = case p of { PClipPop    -> True; _ -> False }
+    isTrPush   p = case p of { PTransformPush _ -> True; _ -> False }
+    isTrPop    p = case p of { PTransformPop    -> True; _ -> False }
+
+-- | 同種 push の入れ子を数えながら、 対応する pop までの内側と残りに割る
+-- (PDF backend の breakMatch と同一。 対応 pop 無し = 末尾まで scope)。
+breakMatch :: (Primitive -> Bool) -> (Primitive -> Bool)
+           -> [Primitive] -> ([Primitive], [Primitive])
+breakMatch isPush isPop = walk (0 :: Int)
+  where
+    walk _ [] = ([], [])
+    walk n (p : rest)
+      | isPop p && n == 0 = ([], rest)
+      | otherwise =
+          let n' = if isPush p then n + 1 else if isPop p then n - 1 else n
+              (inner, after) = walk n' rest
+          in (p : inner, after)
+
+-- | 矩形 clip ('Rect' は左上基準 → y 反転して両角)。
+clipRectOf :: Double -> Rect -> Text
+clipRectOf h (Rect x y w rh) =
+  "\\clip " <> xy x (h - y - rh) <> " rectangle " <> xy (x + w) (h - y) <> ";"
+
+-- | SVG 系 Transform → TikZ cm= 明示行列 (a,b,c,d,(tx,ty))。 y 反転 F が
+-- per-primitive に掛かるため **F∘M∘F (共役)** で写す — PDF backend の
+-- 'matrixOf' と同一式: translate (dx,dy) → (dx,−dy) / scale (sx,sy) →
+-- 平行移動 (0, h(1−sy)) 付き scale。 ※現状 core は PTransformPush を発行しない
+-- (SVG backend も未対応) — 将来の発行に備えた整合実装。
+cmOf :: Double -> Transform -> Text
+cmOf _ (TranslateT dx dy) =
+  "1,0,0,1,(" <> bp dx <> "," <> bp (negate dy) <> ")"
+cmOf h (ScaleT sx sy) =
+  num sx <> ",0,0," <> num sy <> ",(0bp," <> bp (h * (1 - sy)) <> ")"
+
+-- | 単独 primitive → TikZ 行 (0 行 = skip)。 push/pop は 'drawPrims' が先に消費。
+drawOne :: TeXConfig -> Double -> Primitive -> [Text]
+drawOne _   h (PLine a b ls)              = [drawLine h a b ls]
+drawOne _   h (PRect rect fs ms)          = drawRect h rect fs ms
+drawOne _   h (PCircle c rad fs ms _hov)  = drawCircle h c rad fs ms
+drawOne _   h (PPath segs fs ms)          = drawPath h segs fs ms
+drawOne cfg h (PText p s ts)              = [drawText cfg h p s ts]
+drawOne _   _ _                           = []
+
+-- | パス: MoveTo/LineTo/CurveTo/ClosePath を TikZ path 式に写す。
+-- 途中の MoveTo は subpath 切替 (座標の並置 = TikZ の move-to)。
+drawPath :: Double -> [PathSegment] -> FillStyle -> Maybe StrokeStyle -> [Text]
+drawPath _ [] _ _ = []
+drawPath h segs fs ms =
+  case fillDrawOpts fs ms of
+    [] -> []
+    os -> [ T.concat
+      [ "\\path[", T.intercalate ", " os, "]", T.concat (map seg segs), ";" ] ]
+  where
+    seg (MoveTo p)        = " " <> pt h p
+    seg (LineTo p)        = " -- " <> pt h p
+    seg (CurveTo c1 c2 p) = " .. controls " <> pt h c1 <> " and " <> pt h c2
+                            <> " .. " <> pt h p
+    seg ClosePath         = " -- cycle"
+
+-- | 線分: \draw[line width, color, dash pattern]。
+drawLine :: Double -> Point -> Point -> LineStyle -> Text
+drawLine h a b (LineStyle col w dash) = T.concat
+  [ "\\draw[", T.intercalate ", "
+      ([ "line width=" <> bp w, "color=" <> colorRef col ] ++ dashOpt dash)
+  , "] ", pt h a, " -- ", pt h b, ";" ]
+
+-- | LineStyle の dash 配列 (pt) → TikZ dash pattern。 空 = 実線 (option 無し)。
+-- 奇数長は SVG stroke-dasharray と同じく 2 周期に複製して on/off 対にする。
+dashOpt :: [Double] -> [Text]
+dashOpt []   = []
+dashOpt ds   =
+  let ds' = if even (length ds) then ds else ds ++ ds
+      onOff (x : y : rest) = "on " <> bp x <> " off " <> bp y : onOff rest
+      onOff _              = []
+  in [ "dash pattern=" <> T.unwords (onOff ds') ]
+
+-- | 矩形: \path[fill/draw] (x0,y0) rectangle (x1,y1)。 'Rect' は左上基準。
+drawRect :: Double -> Rect -> FillStyle -> Maybe StrokeStyle -> [Text]
+drawRect h (Rect x y w rh) fs ms =
+  case fillDrawOpts fs ms of
+    [] -> []
+    os -> [ T.concat
+      [ "\\path[", T.intercalate ", " os, "] "
+      , xy x (h - y - rh), " rectangle ", xy (x + w) (h - y), ";" ] ]
+
+-- | 円: \path[fill/draw] circle。 hover label は LaTeX では捨てる (PDF と同じ)。
+drawCircle :: Double -> Point -> Double -> FillStyle -> Maybe StrokeStyle
+           -> [Text]
+drawCircle h (Point cx cy) rad fs ms =
+  case fillDrawOpts fs ms of
+    [] -> []
+    os -> [ T.concat
+      [ "\\path[", T.intercalate ", " os, "] "
+      , xy cx (h - cy), " circle [radius=", bp rad, "];" ] ]
+
+-- | fill (色 + opacity) / draw (色 + 線幅) の option 列。 両方無しは [] = skip。
+fillDrawOpts :: FillStyle -> Maybe StrokeStyle -> [Text]
+fillDrawOpts (FillStyle fc opa) ms =
+  (if fc == "none" then []
+   else [ "fill=" <> colorRef fc ]
+     ++ [ "fill opacity=" <> num opa | opa /= 1 ])
+  ++ case ms of
+       Nothing                  -> []
+       Just (StrokeStyle sc sw) ->
+         [ "draw=" <> colorRef sc, "line width=" <> bp sw ]
+
+-- | PText: TikZ node。 anchor = base west/base/base east (SVG の
+-- text-anchor start/middle/end + alphabetic baseline に対応、 A1 probe 検証済)。
+-- tsRotate は core canonical CCW = TikZ CCW で恒等。 font は \sffamily
+-- (DejaVuSans.sty が sans を DejaVu 化) + \fontsize{bp}{1.2bp}。
+--
+-- ラベルの解釈 (A4):
+--   * 全体が @$...$@ の文字列 = **数式 passthrough** (escape せず生で出す。
+--     LaTeX backend の固有価値。 ★他 backend では $ 込みで文字列描画される)
+--   * CJK 文字を含む = 'texCJKFamily' の CJK 環境で包む (pdflatex + CJKutf8)
+drawText :: TeXConfig -> Double -> Point -> Text -> TextStyle -> Text
+drawText cfg h (Point x y) txt ts = T.concat
+  [ "\\node[", T.intercalate ", " opts, "] at ", xy x (h - y)
+  , " {", styleCmds, content, "};" ]
+  where
+    content
+      | isMathLabel txt = txt
+      | T.any (> '\xFF') txt && texCJKMode cfg == CJKWrap = T.concat
+          [ "\\begin{CJK}{UTF8}{", texCJKFamily cfg, "}"
+          , escapeTeX txt, "\\end{CJK}" ]
+      | otherwise       = escapeTeX txt
+    opts = [ "anchor=" <> anchorOf (tsAnchor ts)
+           , "inner sep=0"
+           , "text=" <> colorRef (tsColor ts) ]
+           ++ [ "rotate=" <> num (tsRotate ts) | tsRotate ts /= 0 ]
+    styleCmds = T.concat
+      [ familyCmd (tsFamily ts)
+      , "\\fontsize{", bp (tsSize ts), "}{", bp (1.2 * tsSize ts)
+      , "}\\selectfont "
+      , if tsWeight ts == "bold" then "\\bfseries " else ""
+      , if tsItalic ts then "\\itshape " else "" ]
+    anchorOf AnchorStart  = "base west"
+    anchorOf AnchorMiddle = "base"
+    anchorOf AnchorEnd    = "base east"
+    familyCmd fam
+      | "serif" == fam || "Times" `T.isInfixOf` fam     = "\\rmfamily"
+      | "mono" `T.isInfixOf` fam
+        || "Courier" `T.isInfixOf` fam                  = "\\ttfamily"
+      | otherwise                                       = "\\sffamily"
+
+-- ===========================================================================
+-- 書式 helper
+-- ===========================================================================
+
+-- | 全体が @$...$@ で包まれた数式ラベルか (passthrough 判定)。
+-- 内部に追加の $ を含む場合 (= "$a$ and $b$" のような混在) は数式扱いしない。
+isMathLabel :: Text -> Bool
+isMathLabel t =
+     T.length t > 2
+  && "$" `T.isPrefixOf` t
+  && "$" `T.isSuffixOf` t
+  && not ("$" `T.isInfixOf` T.drop 1 (T.dropEnd 1 t))
+
+-- | LaTeX 特殊文字の escape (text mode)。 数式 passthrough は 'isMathLabel'。
+escapeTeX :: Text -> Text
+escapeTeX = T.concatMap esc
+  where
+    esc '\\' = "\\textbackslash{}"
+    esc '#'  = "\\#"
+    esc '$'  = "\\$"
+    esc '%'  = "\\%"
+    esc '&'  = "\\&"
+    esc '_'  = "\\_"
+    esc '{'  = "\\{"
+    esc '}'  = "\\}"
+    esc '~'  = "\\textasciitilde{}"
+    esc '^'  = "\\textasciicircum{}"
+    esc c    = T.singleton c
+
+-- | 座標 (x, y は既に TikZ 系 = y 反転済で渡る)。
+xy :: Double -> Double -> Text
+xy x y = "(" <> bp x <> "," <> bp y <> ")"
+
+-- | SVG 系座標 → TikZ 座標 (y 反転して括弧書き)。
+pt :: Double -> Point -> Text
+pt h (Point x y) = xy x (h - y)
+
+-- | 寸法 (bp 単位明示)。 固定小数 3 桁 = TikZ が読めない指数表記を回避しつつ
+-- 0.001bp (≈ 0.35µm) 精度で決定論的。
+bp :: Double -> Text
+bp v = num v <> "bp"
+
+-- | 無次元数 (opacity / rotate)。 固定小数 3 桁。
+num :: Double -> Text
+num v = T.pack (showFFloat (Just 3) v "")
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,193 @@
+-- | hgg-latex のテスト (Phase 54)。
+-- 生成 .tex は決定論的な純テキストなので text 内容で固定する
+-- (LaTeX での組版コンパイルは dev-only 検証 = design/phase54-latex/、
+-- test は TeX 環境に依存しない)。
+{-# LANGUAGE OverloadedStrings #-}
+module Main (main) where
+
+import           Graphics.Hgg.Backend.LaTeX (CJKMode (..), TeXConfig (..),
+                                             defaultTeXConfig, luaLaTeXConfig,
+                                             renderPrimitivesTeX, renderTeX,
+                                             renderTeXConfigured)
+import           Graphics.Hgg.Easy
+import           Graphics.Hgg.Layout        (Rect (..))
+import           Graphics.Hgg.Spec          (emptyResolver)
+import           Graphics.Hgg.Render        (FillStyle (..), LineStyle (..),
+                                             PathSegment (..), Point (..),
+                                             Primitive (..), Transform (..))
+import           Data.Text                  (Text)
+import qualified Data.Text                  as T
+import qualified Data.Text.IO               as TIO
+import           Test.Hspec
+
+main :: IO ()
+main = hspec $ do
+  describe "Phase 54 A2: 文書骨格" $ do
+    it "standalone class + tikz + DejaVuSans preamble + bounding box" $ do
+      let tex = renderTeX scatterSpec
+      tex `shouldSatisfy` has "\\documentclass[border=0pt]{standalone}"
+      tex `shouldSatisfy` has "\\usepackage{tikz}"
+      tex `shouldSatisfy` has "\\usepackage{DejaVuSans}"
+      tex `shouldSatisfy` has "\\path[use as bounding box]"
+      tex `shouldSatisfy` has "\\begin{tikzpicture}"
+      tex `shouldSatisfy` has "\\end{document}"
+
+    it "render は決定論的 (同一 spec → byte 一致)" $
+      renderTeX scatterSpec `shouldBe` renderTeX scatterSpec
+
+    it "座標・寸法は明示 bp 単位 + 指数表記なし" $ do
+      let tex = renderTeX scatterSpec
+      tex `shouldSatisfy` has "bp,"
+      -- 1.0e-2 等の指数表記が出たら NG (数字直後の e のみ。 "latex" 等は除外)
+      tex `shouldSatisfy` (not . hasSciNotation)
+
+  describe "Phase 54 A2: 基本 primitive" $ do
+    it "scatter → circle + 軸線 (\\draw) + definecolor 集約" $ do
+      let tex = renderTeX scatterSpec
+      tex `shouldSatisfy` has "circle [radius="
+      tex `shouldSatisfy` has "\\draw["
+      tex `shouldSatisfy` has "\\definecolor{pc"
+
+    it "bar → rectangle" $ do
+      let tex = renderTeX
+            (layer (bar (inlineCat (["a", "b", "a"] :: [String]))
+                        (inline [1, 2, 3])))
+      tex `shouldSatisfy` has "rectangle"
+
+    it "軸目盛ラベル → \\node (anchor + \\sffamily + \\fontsize)" $ do
+      let tex = renderTeX scatterSpec
+      tex `shouldSatisfy` has "\\node[anchor="
+      tex `shouldSatisfy` has "\\sffamily"
+      tex `shouldSatisfy` has "\\fontsize{"
+
+    it "y 軸ラベルの rotate は CCW 恒等 (rotate=90)" $ do
+      let tex = renderTeX (scatterSpec <> yLabel "response")
+      tex `shouldSatisfy` has "rotate=90"
+
+  describe "Phase 54 A2: escape" $ do
+    it "LaTeX 特殊文字 (% $ _ # &) が escape される" $ do
+      let tex = renderTeX (scatterSpec <> title "50% of $x_1 #a & b")
+      tex `shouldSatisfy` has "50\\% of \\$x\\_1 \\#a \\& b"
+
+    it "backslash / brace / tilde / caret も escape される" $ do
+      let tex = renderTeX (scatterSpec <> title "a\\b{c}~d^e")
+      tex `shouldSatisfy` has
+        "a\\textbackslash{}b\\{c\\}\\textasciitilde{}d\\textasciicircum{}e"
+
+  describe "Phase 54 A3: PPath + clip/transform scope" $ do
+    it "density (PPath 経路) → \\path[...] 折れ線 + 例外なし" $ do
+      let tex = renderTeX (layer (density (inline [1, 2, 2, 3, 3, 3, 4, 5])))
+      tex `shouldSatisfy` has " -- "
+
+    it "coordCartesianX → \\begin{scope} + \\clip (対応 \\end{scope} 同数)" $ do
+      let tex = renderTeX
+            (layer (line (inline [1, 2, 3, 4]) (inline [2, 4, 1, 3]))
+             <> coordCartesianX 1.5 3.5)
+      tex `shouldSatisfy` has "\\clip "
+      T.count "\\begin{scope}" tex `shouldBe` T.count "\\end{scope}" tex
+      T.count "\\begin{scope}" tex `shouldSatisfy` (> 0)
+
+    it "CurveTo → .. controls .. 構文 (primitive 直入力)" $ do
+      let prims = [ PPath [ MoveTo (Point 10 10)
+                          , CurveTo (Point 20 10) (Point 20 30) (Point 30 30)
+                          , ClosePath ]
+                          (FillStyle "#ff0000" 1) Nothing ]
+      renderPrimitivesTeX 100 100 prims
+        `shouldSatisfy` has ".. controls (20.000bp,90.000bp) and (20.000bp,70.000bp) .. (30.000bp,70.000bp) -- cycle;"
+
+    it "入れ子 clip は scope が入れ子で対応する" $ do
+      let clip r = PClipPush (Rect r r 50 50)
+          prims  = [ clip 0, clip 10
+                   , PLine (Point 0 0) (Point 9 9)
+                       (LineStyle "#000000" 1 [])
+                   , PClipPop, PClipPop ]
+          tex    = renderPrimitivesTeX 100 100 prims
+      T.count "\\begin{scope}" tex `shouldBe` 2
+      T.count "\\end{scope}" tex `shouldBe` 2
+
+    it "PTransformPush → cm= 共役行列 (PDF backend の matrixOf と同式)" $ do
+      let prims = [ PTransformPush (TranslateT 5 7)
+                  , PLine (Point 0 0) (Point 1 1) (LineStyle "#000000" 1 [])
+                  , PTransformPop
+                  , PTransformPush (ScaleT 2 0.5)
+                  , PLine (Point 0 0) (Point 1 1) (LineStyle "#000000" 1 [])
+                  , PTransformPop ]
+          tex   = renderPrimitivesTeX 100 100 prims
+      tex `shouldSatisfy` has "cm={1,0,0,1,(5.000bp,-7.000bp)}"
+      tex `shouldSatisfy` has "cm={2.000,0,0,0.500,(0bp,50.000bp)}"
+
+  describe "Phase 54 A4: 数式 passthrough / CJK / config" $ do
+    it "全体 $...$ のラベルは escape されず生で出る" $ do
+      let tex = renderTeX (scatterSpec <> title "$\\hat{\\beta}_1 \\pm 1.96$")
+      tex `shouldSatisfy` has "\\selectfont $\\hat{\\beta}_1 \\pm 1.96$}"
+
+    it "混在 ($a$ and $b$) は数式扱いせず escape" $ do
+      let tex = renderTeX (scatterSpec <> title "$a$ and $b$")
+      tex `shouldSatisfy` has "\\$a\\$ and \\$b\\$"
+
+    it "日本語ラベル → CJK 環境 wrap + \\usepackage{CJKutf8} 自動付与" $ do
+      let tex = renderTeX (scatterSpec <> title "応答曲面")
+      tex `shouldSatisfy` has "\\usepackage{CJKutf8}"
+      tex `shouldSatisfy` has "\\begin{CJK}{UTF8}{ipxg}応答曲面\\end{CJK}"
+
+    it "Latin のみなら CJKutf8 は出ない" $ do
+      renderTeX scatterSpec `shouldSatisfy` (not . has "CJKutf8")
+
+    it "texCJKFamily で明朝 (ipxm) に差し替え可" $ do
+      let cfg = defaultTeXConfig { texCJKFamily = "ipxm" }
+          tex = renderTeXConfigured cfg emptyResolver
+                  (scatterSpec <> title "応答曲面")
+      tex `shouldSatisfy` has "{UTF8}{ipxm}"
+
+    it "素片 mode = documentclass 無し + 要 preamble コメント" $ do
+      let cfg = defaultTeXConfig { texStandalone = False }
+          tex = renderTeXConfigured cfg emptyResolver scatterSpec
+      tex `shouldSatisfy` (not . has "\\documentclass")
+      tex `shouldSatisfy` (not . has "\\end{document}")
+      tex `shouldSatisfy` has "% 要 preamble:"
+      tex `shouldSatisfy` has "\\begin{tikzpicture}"
+
+    it "CJKRaw (lualatex/xelatex 向け) = wrap 無し生 UTF-8 + CJKutf8 も出ない" $ do
+      let cfg = defaultTeXConfig { texCJKMode = CJKRaw }
+          tex = renderTeXConfigured cfg emptyResolver
+                  (scatterSpec <> title "応答曲面")
+      tex `shouldSatisfy` (not . has "CJKutf8")
+      tex `shouldSatisfy` (not . has "\\begin{CJK}")
+      tex `shouldSatisfy` has "\\selectfont 応答曲面}"
+
+    it "luaLaTeXConfig = CJKRaw + luatexja preamble (lualatex 実測済 preset)" $ do
+      let tex = renderTeXConfigured luaLaTeXConfig emptyResolver
+                  (scatterSpec <> title "応答曲面")
+      tex `shouldSatisfy` has "\\usepackage{luatexja}"
+      tex `shouldSatisfy` (not . has "CJKutf8")
+      tex `shouldSatisfy` has "\\selectfont 応答曲面}"
+
+    it "texExtraPreamble が preamble に入る (standalone)" $ do
+      let cfg = defaultTeXConfig
+                  { texExtraPreamble = ["\\usepackage{mypkg}"] }
+          tex = renderTeXConfigured cfg emptyResolver scatterSpec
+      tex `shouldSatisfy` has "\\usepackage{mypkg}"
+
+  describe "Phase 54 A5: golden" $
+    it "scatter の生成 .tex が golden fixture と全文一致" $ do
+      golden <- TIO.readFile "test/golden/scatter-golden.tex"
+      renderTeX scatterSpec `shouldBe` golden
+
+scatterSpec :: VisualSpec
+scatterSpec =
+  layer (scatter (inline [1, 2, 3, 4]) (inline [2, 4, 1, 3]))
+
+has :: Text -> Text -> Bool
+has needle hay = needle `T.isInfixOf` hay
+
+-- | "3.0e-2" / "1e5" のような指数表記の数値が含まれるか (数字 + e/E + 符号/数字)。
+hasSciNotation :: Text -> Bool
+hasSciNotation t = any isSci (zip3' (T.unpack t))
+  where
+    isSci (a, b, c) = (a >= '0' && a <= '9')
+                   && (b == 'e' || b == 'E')
+                   && (c == '-' || c == '+' || (c >= '0' && c <= '9'))
+
+-- | 隣接 3 文字組 (Data.Text に無いので List 側で)。
+zip3' :: [Char] -> [(Char, Char, Char)]
+zip3' cs = zip3 cs (drop 1 cs) (drop 2 cs)
diff --git a/test/golden/scatter-golden.tex b/test/golden/scatter-golden.tex
new file mode 100644
--- /dev/null
+++ b/test/golden/scatter-golden.tex
@@ -0,0 +1,51 @@
+% Generated by hgg-latex
+\documentclass[border=0pt]{standalone}
+\usepackage[T1]{fontenc}
+\usepackage{DejaVuSans}
+\usepackage{tikz}
+\definecolor{pcFFFFFF}{HTML}{FFFFFF}
+\definecolor{pcDDDDDD}{HTML}{DDDDDD}
+\definecolor{pc444444}{HTML}{444444}
+\definecolor{pc333333}{HTML}{333333}
+\definecolor{pc1F77B4}{HTML}{1F77B4}
+\begin{document}
+\begin{tikzpicture}
+\path[use as bounding box] (0bp,0bp) rectangle (468.000bp,288.000bp);
+\path[fill=pcFFFFFF] (0.000bp,0.000bp) rectangle (468.000bp,288.000bp);
+\draw[line width=0.500bp, color=pcDDDDDD] (103.730bp,282.500bp) -- (103.730bp,19.250bp);
+\draw[line width=0.500bp, color=pcDDDDDD] (239.115bp,282.500bp) -- (239.115bp,19.250bp);
+\draw[line width=0.500bp, color=pcDDDDDD] (374.500bp,282.500bp) -- (374.500bp,19.250bp);
+\draw[line width=1.000bp, color=pcDDDDDD] (36.038bp,282.500bp) -- (36.038bp,19.250bp);
+\draw[line width=1.000bp, color=pcDDDDDD] (171.423bp,282.500bp) -- (171.423bp,19.250bp);
+\draw[line width=1.000bp, color=pcDDDDDD] (306.807bp,282.500bp) -- (306.807bp,19.250bp);
+\draw[line width=1.000bp, color=pcDDDDDD] (442.192bp,282.500bp) -- (442.192bp,19.250bp);
+\draw[line width=0.500bp, color=pcDDDDDD] (15.730bp,71.102bp) -- (462.500bp,71.102bp);
+\draw[line width=0.500bp, color=pcDDDDDD] (15.730bp,150.875bp) -- (462.500bp,150.875bp);
+\draw[line width=0.500bp, color=pcDDDDDD] (15.730bp,230.648bp) -- (462.500bp,230.648bp);
+\draw[line width=1.000bp, color=pcDDDDDD] (15.730bp,31.216bp) -- (462.500bp,31.216bp);
+\draw[line width=1.000bp, color=pcDDDDDD] (15.730bp,110.989bp) -- (462.500bp,110.989bp);
+\draw[line width=1.000bp, color=pcDDDDDD] (15.730bp,190.761bp) -- (462.500bp,190.761bp);
+\draw[line width=1.000bp, color=pcDDDDDD] (15.730bp,270.534bp) -- (462.500bp,270.534bp);
+\path[fill=pcFFFFFF, fill opacity=0.000, draw=pc444444, line width=1.000bp] (15.730bp,19.250bp) rectangle (462.500bp,282.500bp);
+\draw[line width=1.000bp, color=pc444444] (36.038bp,19.250bp) -- (36.038bp,16.500bp);
+\node[anchor=base, inner sep=0, text=pc333333] at (36.038bp,7.260bp) {\sffamily\fontsize{8.800bp}{10.560bp}\selectfont 1};
+\draw[line width=1.000bp, color=pc444444] (171.423bp,19.250bp) -- (171.423bp,16.500bp);
+\node[anchor=base, inner sep=0, text=pc333333] at (171.423bp,7.260bp) {\sffamily\fontsize{8.800bp}{10.560bp}\selectfont 2};
+\draw[line width=1.000bp, color=pc444444] (306.807bp,19.250bp) -- (306.807bp,16.500bp);
+\node[anchor=base, inner sep=0, text=pc333333] at (306.807bp,7.260bp) {\sffamily\fontsize{8.800bp}{10.560bp}\selectfont 3};
+\draw[line width=1.000bp, color=pc444444] (442.192bp,19.250bp) -- (442.192bp,16.500bp);
+\node[anchor=base, inner sep=0, text=pc333333] at (442.192bp,7.260bp) {\sffamily\fontsize{8.800bp}{10.560bp}\selectfont 4};
+\draw[line width=1.000bp, color=pc444444] (15.730bp,31.216bp) -- (12.980bp,31.216bp);
+\node[anchor=base east, inner sep=0, text=pc333333] at (10.780bp,28.136bp) {\sffamily\fontsize{8.800bp}{10.560bp}\selectfont 1};
+\draw[line width=1.000bp, color=pc444444] (15.730bp,110.989bp) -- (12.980bp,110.989bp);
+\node[anchor=base east, inner sep=0, text=pc333333] at (10.780bp,107.909bp) {\sffamily\fontsize{8.800bp}{10.560bp}\selectfont 2};
+\draw[line width=1.000bp, color=pc444444] (15.730bp,190.761bp) -- (12.980bp,190.761bp);
+\node[anchor=base east, inner sep=0, text=pc333333] at (10.780bp,187.681bp) {\sffamily\fontsize{8.800bp}{10.560bp}\selectfont 3};
+\draw[line width=1.000bp, color=pc444444] (15.730bp,270.534bp) -- (12.980bp,270.534bp);
+\node[anchor=base east, inner sep=0, text=pc333333] at (10.780bp,267.454bp) {\sffamily\fontsize{8.800bp}{10.560bp}\selectfont 4};
+\path[fill=pc1F77B4, fill opacity=0.850] (36.038bp,110.989bp) circle [radius=2.339bp];
+\path[fill=pc1F77B4, fill opacity=0.850] (171.423bp,270.534bp) circle [radius=2.339bp];
+\path[fill=pc1F77B4, fill opacity=0.850] (306.807bp,31.216bp) circle [radius=2.339bp];
+\path[fill=pc1F77B4, fill opacity=0.850] (442.192bp,190.761bp) circle [radius=2.339bp];
+\end{tikzpicture}
+\end{document}
