packages feed

hgg-pdf (empty) → 0.1.0.0

raw patch · 5 files changed

+511/−0 lines, 5 filesdep +HPDFdep +basedep +bytestring

Dependencies added: HPDF, base, bytestring, directory, filepath, hgg-core, hgg-frame, hgg-pdf, hspec, text

Files

+ CHANGELOG.md view
@@ -0,0 +1,7 @@+# Changelog for `hgg-pdf`++## 0.1.0.0 — 2026-07-18++First public release on Hackage.++- PDF backend (HPDF).
+ LICENSE view
@@ -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.
+ hgg-pdf.cabal view
@@ -0,0 +1,50 @@+cabal-version:      3.0+name:               hgg-pdf+version:            0.1.0.0+extra-doc-files:    CHANGELOG.md+synopsis:           PDF backend for hgg (HPDF)+description:+  Vector PDF backend for hgg. Interprets the [Primitive] list from+  hgg-core into HPDF (Graphics.PDF, pure Haskell, BSD-3) drawing+  commands, in the same single-pass architecture as the SVG backend.+  Currently limited to the 14 standard PDF fonts (Latin only); for CJK+  labels use hgg-rasterific (PNG + TrueType) instead.+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++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.PDF+  hs-source-dirs:   src+  build-depends:    base               >= 4.17 && < 5+                  , text               >= 2.0  && < 2.2+                  , HPDF               >= 1.7  && < 1.9+                  , hgg-core  ^>= 0.1+                  , hgg-frame ^>= 0.1+  default-language: Haskell2010++test-suite hgg-pdf-tests+  import:           warnings+  type:             exitcode-stdio-1.0+  main-is:          Spec.hs+  hs-source-dirs:   test+  default-language: Haskell2010+  build-depends:    base+                  , bytestring+                  , directory+                  , filepath+                  , hspec              >= 2.10 && < 2.12+                  , text+                  , hgg-core+                  , hgg-pdf
+ src/Graphics/Hgg/Backend/PDF.hs view
@@ -0,0 +1,346 @@+-- |+-- Module      : Graphics.Hgg.Backend.PDF+-- Description : ベクタ PDF backend (Phase 17、 HPDF)+-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)+-- License     : BSD-3-Clause+--+-- plot-core の '[Primitive]' を HPDF (Graphics.PDF) の 'P.Draw' 命令に+-- 解釈する (SVG backend と同じ Layer 1 構図)。 座標系は per-primitive で+-- y 反転 (PDF = 左下原点 y 上向き / Primitive = SVG 系 左上原点 y 下向き。+-- global flip だと text が鏡像になるため per-primitive 変換)。+--+-- ★v1 制約: フォントは PDF 標準 14 種 (Latin) のみ — 日本語ラベルは+-- 出せない (非 Latin-1 文字は警告 + @?@ 置換)。 日本語の受け皿は+-- hgg-rasterific (PNG + TrueType)。+{-# LANGUAGE OverloadedStrings #-}+module Graphics.Hgg.Backend.PDF+  ( -- * 通常 (Resolver 不要 = inline 列のみの図)+    savePDF+    -- * Resolver 同伴 (= 'ColByName' を含む図)+  , savePDFWith+    -- * Phase 14 系: BoundPlot (df バインド済) を描画する+  , savePDFBound+    -- * 低レベル: [Primitive] を直接描画 (Phase 24 A8・3D backend glue 用)+  , savePrimitivesPDF+  ) 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             (digitToInt, isHexDigit)+import           Data.Complex          (Complex ((:+)))+import           Data.Text             (Text)+import qualified Data.Text             as T+import qualified Graphics.PDF          as P+import           System.IO             (hPutStrLn, stderr)++-- ===========================================================================+-- 入口 (SVG backend の saveSVG / saveSVGWith / saveSVGBound と対称)+-- ===========================================================================++-- | PDF ファイルに保存。 Resolver 不要 (= inline 列のみの図、 = 通常)。+-- 列名参照を含む図は 'savePDFWith'、 DataFrame は 'savePDFBound' (@df |>> spec@)。+savePDF :: FilePath -> VisualSpec -> IO ()+savePDF path = savePDFWith path emptyResolver++-- | 'Resolver' を渡して PDF ファイルに保存。 'ColByName' を含む図用。+savePDFWith :: FilePath -> Resolver -> VisualSpec -> IO ()+savePDFWith path r spec = do+  -- ★ Phase 33 B5: PDF は point ネイティブ (PDFRect 単位 = pt) ゆえ k=1。layout/prims+  --   は純 pt なので scalePrimitives 不要 (恒等)・viewport pt をそのまま頁サイズに。+  --   raster backend のような dpi 乗算をするとサイズが二重変換になるので禁止。+  let layout = computeLayout r spec+      prims  = renderToPrimitives r layout spec+      ViewportSize w h = lpViewport layout+  savePrimitivesPDF path w h prims++-- | Phase 24 A8: [Primitive] 列を所与のキャンバスサイズで PDF に直接描画する+-- 低レベル経路 ('savePrimitivesSVG' の PDF 版)。 2D の 'savePDFWith' と 3D の+-- 'savePDF3D' が共有。 非 Latin-1 ラベルは警告 + @?@ 置換 (v1 制約)。+savePrimitivesPDF :: FilePath -> Int -> Int -> [Primitive] -> IO ()+savePrimitivesPDF path w h prims = do+  let (wD, hD) = (fromIntegral w, fromIntegral h)+  warnNonLatin prims+  fonts <- loadStdFonts+  P.runPdf path P.standardDocInfo (P.PDFRect 0 0 wD hD) $ do+    page <- P.addPage Nothing+    P.drawWithPage page (drawPrims fonts hD prims)++-- | v1 制約の loud 化: 非 Latin-1 文字を含むラベルがあれば stderr に 1 回警告+-- (描画では @?@ に置換される)。 日本語ラベルは PNG backend+-- (hgg-rasterific) を使う。+warnNonLatin :: [Primitive] -> IO ()+warnNonLatin prims+  | any hasNonLatin prims =+      hPutStrLn stderr+        "[hgg-pdf] 警告: 非 Latin-1 文字を含むラベルがあります。 \+        \PDF 標準フォントでは描けないため ? に置換されます (v1 制約)。 \+        \日本語ラベルは PNG backend (hgg-rasterific) を使ってください。"+  | otherwise = pure ()+  where+    hasNonLatin (PText _ s _) = T.any (> '\xFF') s+    hasNonLatin _             = False++-- | 'BoundPlot' (= @df |>> spec@ の結果) を PDF ファイルに保存。+-- Error severity の検証診断は stderr に報告してから書き出す+-- (saveSVGBound と同じ lenient 既定)。+savePDFBound :: FilePath -> BoundPlot -> IO ()+savePDFBound path (BoundPlot r spec diags) = do+  mapM_ (hPutStrLn stderr . T.unpack . renderDiagnostic)+        (filter ((== SevError) . diagnosticSeverity) diags)+  savePDFWith path r spec++-- ===========================================================================+-- Primitive 解釈器+-- ===========================================================================++-- | Primitive 列を順に描く。 第 1 引数 = viewport 高さ (y 反転用)。+--+-- PClipPush/PTransformPush は対応する Pop までを**再帰グルーピング**して+-- 'P.withNewContext' に入れる (PDF の clip / CTM は graphics state 復元で+-- しか戻せないため。 SVG backend は \<g\> 開閉で済むのと対照的)。+-- 対応の取れない Pop は黙って無視 (SVG backend と同じ寛容さ)。+drawPrims :: StdFonts -> Double -> [Primitive] -> P.Draw ()+drawPrims fonts h = go+  where+    go [] = pure ()+    go (PClipPush rect : rest) =+      let (inner, after) = breakMatch isClipPush isClipPop rest+      in do P.withNewContext $ do+              clipRectOf h rect+              go inner+            go after+    go (PTransformPush tr : rest) =+      let (inner, after) = breakMatch isTrPush isTrPop rest+      in do P.withNewContext $ do+              P.applyMatrix (matrixOf h tr)+              go inner+            go after+    go (PClipPop : rest)      = go rest+    go (PTransformPop : rest) = go rest+    go (p : rest)             = drawOne fonts 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 までの内側と残りに割る。+-- 対応 pop が無ければ全部内側 (= 末尾まで clip が効く・SVG の開きっ放しと同義)。+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)++-- | 単独 primitive の描画。 push/pop は 'drawPrims' が先に消費する。+drawOne :: StdFonts -> Double -> Primitive -> P.Draw ()+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 _hover) = drawCircle h c rad fs ms+drawOne _ h (PPath segs fs ms)           = drawPath h segs fs ms+drawOne fonts h (PText p s ts)           = drawTextPrim fonts h p s ts+drawOne _ _ _                            = pure ()++-- | 矩形 clip: 矩形 path を current path にして 'P.setAsClipPath'。+clipRectOf :: Double -> Rect -> P.Draw ()+clipRectOf h (Rect x y w rh) = do+  P.addShape (P.Rectangle (x :+ (h - y - rh)) ((x + w) :+ (h - y)))+  P.setAsClipPath++-- | SVG 系 Transform → PDF Matrix。 y 反転 F (y↦h−y) は per-primitive に+-- 掛かるため、 SVG 空間の変換 M は **F∘M∘F (共役)** で PDF 空間に写す:+-- translate (dx,dy) → translate (dx,−dy) / scale (sx,sy) →+-- translate (0, h(1−sy)) ∘ scale (sx,sy)。 ※現状 core は PTransformPush を+-- 発行しない (SVG backend も未対応) — 将来の発行に備えた整合実装。+matrixOf :: Double -> Transform -> P.Matrix+matrixOf _ (TranslateT dx dy) = P.translate (dx :+ negate dy)+matrixOf h (ScaleT sx sy)     =+  P.translate (0 :+ (h * (1 - sy))) * P.scale sx sy++-- | パス: MoveTo/LineTo/CurveTo/ClosePath を HPDF path 命令に写して+-- fill / stroke / 両方 を塗り分ける。+drawPath :: Double -> [PathSegment] -> FillStyle -> Maybe StrokeStyle+         -> P.Draw ()+drawPath _ [] _ _ = pure ()+drawPath h segs fs@(FillStyle fc _) ms = P.withNewContext $ do+  applyFill fs+  applyStroke ms+  case segs of+    (MoveTo p : rest) -> P.beginPath (pdfPt h p) >> mapM_ seg rest+    _                 -> P.beginPath (0 :+ 0) >> mapM_ seg segs+  paint+  where+    seg (MoveTo p)         = P.moveto (pdfPt h p)+    seg (LineTo p)         = P.addLineToPath (pdfPt h p)+    seg (CurveTo c1 c2 p)  = P.addBezierCubic (pdfPt h c1) (pdfPt h c2) (pdfPt h p)+    seg ClosePath          = P.closePath+    paint = case (fc == "none", ms) of+      (True,  Just _)  -> P.strokePath+      (True,  Nothing) -> pure ()+      (False, Just _)  -> P.fillAndStrokePath+      (False, Nothing) -> P.fillPath++applyFill :: FillStyle -> P.Draw ()+applyFill (FillStyle col opa)+  | col == "none" = pure ()+  | otherwise     = P.fillColor (colorOf col) >> P.setFillAlpha opa++applyStroke :: Maybe StrokeStyle -> P.Draw ()+applyStroke Nothing                    = pure ()+applyStroke (Just (StrokeStyle sc sw)) =+  P.strokeColor (colorOf sc) >> P.setWidth sw++-- | SVG 系座標 (左上原点 y 下) → PDF 座標 (左下原点 y 上)。+pdfPt :: Double -> Point -> P.Point+pdfPt h (Point x y) = x :+ (h - y)++-- | 線分: 色/幅/破線を設定して stroke。+drawLine :: Double -> Point -> Point -> LineStyle -> P.Draw ()+drawLine h a b (LineStyle col w dash) = P.withNewContext $ do+  P.strokeColor (colorOf col)+  P.setWidth w+  setDashOf dash+  P.beginPath (pdfPt h a)+  P.addLineToPath (pdfPt h b)+  P.strokePath++-- | 矩形: fill (+ 任意 stroke)。 'Rect' は左上基準なので下端 = y + h 側。+drawRect :: Double -> Rect -> FillStyle -> Maybe StrokeStyle -> P.Draw ()+drawRect h (Rect x y w rh) fs ms = P.withNewContext $ do+  let shape = P.Rectangle (x :+ (h - y - rh)) ((x + w) :+ (h - y))+  fillShape shape fs ms++-- | 円: fill (+ 任意 stroke)。 hover label は PDF では捨てる。+drawCircle :: Double -> Point -> Double -> FillStyle -> Maybe StrokeStyle+           -> P.Draw ()+drawCircle h (Point cx cy) rad fs ms = P.withNewContext $ do+  let shape = P.Circle cx (h - cy) rad+  fillShape shape fs ms++-- | Shape 共通: FillStyle (色 + opacity) で塗り、 StrokeStyle があれば縁取る。+fillShape :: P.Shape a => a -> FillStyle -> Maybe StrokeStyle -> P.Draw ()+fillShape shape (FillStyle col opa) ms = do+  if col == "none"+    then pure ()+    else do+      P.fillColor (colorOf col)+      P.setFillAlpha opa+      P.fill shape+  case ms of+    Nothing -> pure ()+    Just (StrokeStyle sc sw) -> do+      P.strokeColor (colorOf sc)+      P.setWidth sw+      P.stroke shape++-- | LineStyle の dash 配列 (px) → HPDF DashPattern。 空 = 実線。+setDashOf :: [Double] -> P.Draw ()+setDashOf []   = P.setNoDash+setDashOf dash = P.setDash (P.DashPattern dash 0)++-- ===========================================================================+-- テキスト (Phase 17 A3) — PDF 標準フォント (Latin のみ・v1 制約)+-- ===========================================================================++-- | 標準フォント束 (3 family × 4 変種)。 'mkStdFont' は AFM parse を伴う IO+-- なので savePDF 入口で 1 回 load して 'P.Draw' 解釈器に渡す。+data StdFonts = StdFonts+  { sfSans  :: (P.AnyFont, P.AnyFont, P.AnyFont, P.AnyFont)+    -- ^ Helvetica (regular, bold, oblique, bold-oblique)+  , sfSerif :: (P.AnyFont, P.AnyFont, P.AnyFont, P.AnyFont)+    -- ^ Times+  , sfMono  :: (P.AnyFont, P.AnyFont, P.AnyFont, P.AnyFont)+    -- ^ Courier+  }++loadStdFonts :: IO StdFonts+loadStdFonts = do+  let load fn = do+        ef <- P.mkStdFont fn+        case ef of+          Right f  -> pure f+          Left err -> errorWithoutStackTrace+            ("hgg-pdf: 標準フォントの load に失敗: " ++ show err)+      load4 (a, b, c, d) = (,,,) <$> load a <*> load b <*> load c <*> load d+  StdFonts+    <$> load4 (P.Helvetica, P.Helvetica_Bold,+               P.Helvetica_Oblique, P.Helvetica_BoldOblique)+    <*> load4 (P.Times_Roman, P.Times_Bold,+               P.Times_Italic, P.Times_BoldItalic)+    <*> load4 (P.Courier, P.Courier_Bold,+               P.Courier_Oblique, P.Courier_BoldOblique)++-- | family ("serif" / "monospace" 系は対応 family、 他は Helvetica) ++-- weight/italic で 12 変種から選ぶ。+selectFont :: StdFonts -> Text -> Text -> Bool -> P.AnyFont+selectFont fonts fam weight italic =+  let (r, b, o, bo)+        | "serif" == fam || "Times" `T.isInfixOf` fam = sfSerif fonts+        | "mono" `T.isInfixOf` fam || "Courier" `T.isInfixOf` fam = sfMono fonts+        | otherwise = sfSans fonts+  in case (weight == "bold", italic) of+       (True,  True)  -> bo+       (True,  False) -> b+       (False, True)  -> o+       (False, False) -> r++-- | PText: anchor は 'P.textWidth' (AFM metrics) で x 補正、 tsRotate+-- (degrees CW・SVG 同義) は (x,y) 周りの回転 = PDF (y 上向き) では符号反転。+-- 非 Latin-1 文字は @?@ 置換 (警告は 'warnNonLatin' が IO 側で 1 回出す)。+drawTextPrim :: StdFonts -> Double -> Point -> Text -> TextStyle -> P.Draw ()+drawTextPrim fonts h (Point x y) txt ts = P.withNewContext $ do+  let anyf  = selectFont fonts (tsFamily ts) (tsWeight ts) (tsItalic ts)+      size  = max 1 (round (tsSize ts)) :: Int+      font  = P.PDFFont anyf size+      clean = T.map (\c -> if c > '\xFF' then '?' else c) txt+      tw    = P.textWidth font clean+      dx    = case tsAnchor ts of+                AnchorStart  -> 0+                AnchorMiddle -> negate (tw / 2)+                AnchorEnd    -> negate tw+  P.fillColor (colorOf (tsColor ts))+  if tsRotate ts == 0+    then P.drawText $ do+           P.setFont font+           P.textStart (x + dx) (h - y)+           P.displayText clean+    else do+      P.applyMatrix (P.translate (x :+ (h - y)))+      -- Phase 50 A1: 内部 tsRotate は CCW 正 (canonical)。 PDF/PostScript は y-up で CCW 正+      --   ゆえ **恒等** で渡す (旧: CW canonical を negate していた。 canonical CCW 化で解消)。+      P.applyMatrix (P.rotate (P.Degree (tsRotate ts)))+      P.drawText $ do+        P.setFont font+        P.textStart dx 0+        P.displayText clean++-- ===========================================================================+-- 色 (theme 色は全て "#rrggbb" hex。 named / 不正は黒 fallback)+-- ===========================================================================++colorOf :: Text -> P.Color+colorOf t = case T.unpack t of+  ['#', r1, r2, g1, g2, b1, b2]+    | all isHexDigit [r1, r2, g1, g2, b1, b2] ->+        P.Rgb (hex2 r1 r2) (hex2 g1 g2) (hex2 b1 b2)+  ['#', r, g, b]+    | all isHexDigit [r, g, b] -> P.Rgb (hex2 r r) (hex2 g g) (hex2 b b)+  "white" -> P.Rgb 1 1 1+  _       -> P.Rgb 0 0 0+  where+    hex2 a b = fromIntegral (digitToInt a * 16 + digitToInt b) / (255 :: Double)
+ test/Spec.hs view
@@ -0,0 +1,78 @@+-- | hgg-pdf のテスト (Phase 17)。+-- PDF バイト列はメタデータ等で非決定になり得るため golden 比較はせず、+-- 「%PDF- 先頭 + 例外なし + サイズ下限」 を固定する (計画 md のリスク欄)。+{-# LANGUAGE OverloadedStrings #-}+module Main (main) where++import           Graphics.Hgg.Backend.PDF (savePDF)+import           Graphics.Hgg.Easy+import qualified Data.ByteString.Char8    as BS+import           System.Directory         (getTemporaryDirectory, removeFile)+import           System.FilePath          ((</>))+import           Test.Hspec++main :: IO ()+main = hspec $ do+  describe "Phase 17 A1: savePDF (骨格 = line/rect/circle)" $ do+    it "scatter spec が %PDF- 先頭の非自明なファイルを書く" $ do+      tmp <- getTemporaryDirectory+      let path = tmp </> "hgg-pdf-test-scatter.pdf"+          spec = layer (scatter (inline [1, 2, 3, 4]) (inline [2, 4, 1, 3]))+      savePDF path spec+      bs <- BS.readFile path+      BS.take 5 bs `shouldBe` "%PDF-"+      BS.length bs `shouldSatisfy` (> 500)+      removeFile path++    it "bar spec (rect 経路) も例外なく書ける" $ do+      tmp <- getTemporaryDirectory+      let path = tmp </> "hgg-pdf-test-bar.pdf"+          spec = layer (bar (inlineCat (["a", "b", "a"] :: [String]))+                            (inline [1, 2, 3]))+      savePDF path spec+      bs <- BS.readFile path+      BS.take 5 bs `shouldBe` "%PDF-"+      removeFile path++  describe "Phase 17 A2: PPath + clip 再帰グルーピング" $ do+    it "density (PPath 経路) が例外なく書ける" $ do+      tmp <- getTemporaryDirectory+      let path = tmp </> "hgg-pdf-test-density.pdf"+          spec = layer (density (inline [1, 2, 2, 3, 3, 3, 4, 5]))+      savePDF path spec+      bs <- BS.readFile path+      BS.take 5 bs `shouldBe` "%PDF-"+      removeFile path++    it "coordCartesianX (PClipPush/Pop 経路) が例外なく書ける" $ do+      tmp <- getTemporaryDirectory+      let path = tmp </> "hgg-pdf-test-clip.pdf"+          spec = layer (line (inline [1, 2, 3, 4]) (inline [2, 4, 1, 3]))+                 <> coordCartesianX 1.5 3.5+      savePDF path spec+      bs <- BS.readFile path+      BS.take 5 bs `shouldBe` "%PDF-"+      removeFile path++  describe "Phase 17 A3: PText (標準フォント・anchor/rotate)" $ do+    it "title + 軸ラベル (回転 y ラベル含む) が例外なく書ける" $ do+      tmp <- getTemporaryDirectory+      let path = tmp </> "hgg-pdf-test-text.pdf"+          spec = layer (scatter (inline [1, 2, 3]) (inline [3, 1, 2]))+                 <> title "Latin title"+                 <> xLabel "weight" <> yLabel "mpg"+      savePDF path spec+      bs <- BS.readFile path+      BS.take 5 bs `shouldBe` "%PDF-"+      BS.length bs `shouldSatisfy` (> 1000)+      removeFile path++    it "非 Latin-1 ラベルでも crash しない (? 置換 + 警告)" $ do+      tmp <- getTemporaryDirectory+      let path = tmp </> "hgg-pdf-test-cjk.pdf"+          spec = layer (scatter (inline [1, 2, 3]) (inline [3, 1, 2]))+                 <> title "日本語タイトル"+      savePDF path spec+      bs <- BS.readFile path+      BS.take 5 bs `shouldBe` "%PDF-"+      removeFile path