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 +7/−0
- LICENSE +30/−0
- hgg-pdf.cabal +50/−0
- src/Graphics/Hgg/Backend/PDF.hs +346/−0
- test/Spec.hs +78/−0
+ 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