hgg-rasterific-0.1.0.0: src/Graphics/Hgg/Backend/Rasterific.hs
-- |
-- Module : Graphics.Hgg.Backend.Rasterific
-- Description : raster PNG backend (Phase 22、 Rasterific + FontyFruity)
-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)
-- License : BSD-3-Clause
--
-- plot-core の '[Primitive]' を Rasterific の 'R.Drawing' 命令に解釈して
-- JuicyPixels で PNG エンコードする (SVG/PDF backend と同じ Layer 1 構図)。
-- Rasterific は y 下向き (SVG 同系) なので **y 反転は不要** (PDF と対照的)。
--
-- フォントは FontyFruity の TrueType 読込 = **日本語ラベル対応**
-- (PDF v1 制約の受け皿)。 探索は fontconfig 非依存の固定候補リスト
-- (明示 'pngFontPath' → 既知ディレクトリ × 既知ファイル名)。
-- ★FontyFruity は .ttc (TrueType Collection) / CFF 系 OTF 非対応 —
-- 見つからない時は探索パスを列挙して loud エラー。
{-# LANGUAGE OverloadedStrings #-}
module Graphics.Hgg.Backend.Rasterific
( -- * 通常 (Resolver 不要 = inline 列のみの図)
savePNG
-- * Resolver 同伴 (= 'ColByName' を含む図)
, savePNGWith
-- * Phase 14 系: BoundPlot (df バインド済) を描画する
, savePNGBound
-- * 設定付き (フォント明示 / Hi-DPI)
, savePNGConfigured
, PNGConfig (..)
, defaultPNGConfig
-- * 低レベル: [Primitive] を直接描画 (Phase 24 A8・3D backend glue 用)
, savePrimitivesPNG
-- * フォント解決 (診断 / テスト用)
, PNGFonts (..)
, loadPNGFonts
) 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,
scalePrimitives)
import Graphics.Hgg.Spec (Resolver, VisualSpec, emptyResolver,
vsDpi)
import Data.Monoid (getLast)
import Graphics.Hgg.Validate (Severity (..), diagnosticSeverity,
renderDiagnostic)
import Codec.Picture (PixelRGBA8 (..), writePng)
import Data.Char (digitToInt, isHexDigit, toLower)
import Data.List (intercalate)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Graphics.Rasterific as R
import qualified Graphics.Rasterific.Texture as RT
import qualified Graphics.Rasterific.Transformations as RTr
import qualified Graphics.Text.TrueType as F
import System.Directory (doesDirectoryExist, doesFileExist,
getHomeDirectory, listDirectory)
import System.FilePath (takeExtension, (</>))
import System.IO (hPutStrLn, stderr)
-- ===========================================================================
-- 設定
-- ===========================================================================
-- | PNG 出力設定。 'defaultPNGConfig' から record update で部分指定する。
data PNGConfig = PNGConfig
{ pngFontPath :: Maybe FilePath
-- ^ regular TTF の明示指定 (最優先)。 Nothing = 候補リスト探索
, pngFontPathBold :: Maybe FilePath
-- ^ bold TTF。 Nothing = bold 候補探索 → 無ければ regular で代替
, pngScale :: Double
-- ^ Hi-DPI 倍率 (既定 1.0 = SVG と同 pixel 寸法)。 2.0 で縦横 2 倍
} deriving (Show, Eq)
defaultPNGConfig :: PNGConfig
defaultPNGConfig = PNGConfig
{ pngFontPath = Nothing
, pngFontPathBold = Nothing
, pngScale = 1.0
}
-- ===========================================================================
-- 入口 (PDF backend の savePDF / savePDFWith / savePDFBound と対称)
-- ===========================================================================
-- | PNG ファイルに保存。 Resolver 不要 (= inline 列のみの図、 = 通常)。
-- 列名参照を含む図は 'savePNGWith'、 DataFrame は 'savePNGBound' (@df |>> spec@)。
savePNG :: FilePath -> VisualSpec -> IO ()
savePNG path = savePNGWith path emptyResolver
-- | 'Resolver' を渡して PNG ファイルに保存。 'ColByName' を含む図用。
savePNGWith :: FilePath -> Resolver -> VisualSpec -> IO ()
savePNGWith = savePNGConfigured defaultPNGConfig
-- | 'BoundPlot' (= @df |>> spec@ の結果) を PNG ファイルに保存。
-- Error severity の検証診断は stderr に報告してから書き出す
-- (savePDFBound と同じ lenient 既定)。
savePNGBound :: FilePath -> BoundPlot -> IO ()
savePNGBound path (BoundPlot r spec diags) = do
mapM_ (hPutStrLn stderr . T.unpack . renderDiagnostic)
(filter ((== SevError) . diagnosticSeverity) diags)
savePNGWith path r spec
-- | 'PNGConfig' 付き保存 (フォント明示 / Hi-DPI)。
savePNGConfigured :: PNGConfig -> FilePath -> Resolver -> VisualSpec -> IO ()
savePNGConfigured cfg path r spec = do
-- ★ Phase 33 B5: layout/prims は純 pt。PNG は raster なので k=dpi/72 を一度だけ
-- 掛けて device px へ (HS SVG backend と同一・唯一の dpi 適用点)。font size も
-- scalePrimitives で device px になり、drawTextPrim の px→point 変換はそのまま整合。
-- pngScale (Hi-DPI) は k と直交の追加倍率として savePrimitivesPNG 側で温存。
let layout = computeLayout r spec
k = maybe 96 id (getLast (vsDpi spec)) / 72
prims = scalePrimitives k (renderToPrimitives r layout spec)
ViewportSize wpt hpt = lpViewport layout
w = round (fromIntegral wpt * k) :: Int
h = round (fromIntegral hpt * k) :: Int
savePrimitivesPNG cfg path w h prims
-- | Phase 24 A8: [Primitive] 列を所与のキャンバスサイズで PNG に直接描画する
-- 低レベル経路 ('savePrimitivesSVG' の PNG 版)。 2D の 'savePNGConfigured' と
-- 3D の 'savePNG3D' が共有。 'pngScale' で Hi-DPI 拡大。
savePrimitivesPNG :: PNGConfig -> FilePath -> Int -> Int -> [Primitive] -> IO ()
savePrimitivesPNG cfg path w h prims = do
let s = max 1e-3 (pngScale cfg)
wI = max 1 (ceiling (fromIntegral w * s)) :: Int
hI = max 1 (ceiling (fromIntegral h * s)) :: Int
fonts <- loadPNGFonts cfg
-- 画像初期化は白。 theme 背景は [Primitive] 先頭の背景 rect が全面を塗る
-- (Render/Layer.hs の background) ので実質 theme 色になる。
let bg = PixelRGBA8 255 255 255 255
img = R.renderDrawing wI hI bg $
R.withTransformation (RTr.scale (f s) (f s)) $
drawPrims fonts prims
writePng path img
-- ===========================================================================
-- フォント探索 (fontconfig 非依存・決定的)
-- ===========================================================================
-- | 解釈器に渡すフォント束。 v1 は family 非区別 (regular/bold の 2 face のみ。
-- 日本語 .ttf で serif/italic が揃う環境は稀のため。 計画 md の設計判断)。
data PNGFonts = PNGFonts
{ pfRegular :: F.Font
, pfBold :: F.Font
}
-- | 候補ディレクトリ (存在するものだけ走査・/usr/share/fonts は再帰)。
fontSearchDirs :: IO [FilePath]
fontSearchDirs = do
home <- getHomeDirectory
pure
[ home </> ".fonts"
, home </> ".local/share/fonts"
, "/usr/share/fonts"
, "/usr/local/share/fonts"
, "/mnt/c/Windows/Fonts" -- WSL (※日本語は .ttc が多く対象外になりがち)
]
-- | 候補ファイル名 (優先順・小文字比較)。 日本語対応 .ttf を先頭に、
-- 最後に Latin のみの DejaVu (JP フォント不在環境の文字化け回避より
-- 「とりあえず描ける」 を優先。 JP が必要なら pngFontPath で明示)。
regularCandidates :: [String]
regularCandidates =
[ "hackgen-regular.ttf"
, "notosanscjkjp-regular.ttf"
, "notosansjp-regular.ttf"
, "ipagp.ttf" -- IPA P ゴシック
, "ipag.ttf" -- IPA ゴシック
, "takaopgothic.ttf"
, "takaogothic.ttf"
, "dejavusans.ttf"
]
boldCandidates :: [String]
boldCandidates =
[ "hackgen-bold.ttf"
, "notosanscjkjp-bold.ttf"
, "notosansjp-bold.ttf"
, "dejavusans-bold.ttf"
]
-- | フォント load。 明示 path → 候補探索 → loud エラー。
loadPNGFonts :: PNGConfig -> IO PNGFonts
loadPNGFonts cfg = do
index <- ttfIndex
reg <- resolveFont index "regular" (pngFontPath cfg) regularCandidates
bold <- case pngFontPathBold cfg of
Just p -> loadOrDie p
Nothing -> case lookupCandidates index boldCandidates of
Just p -> loadOrDie p
Nothing -> pure reg -- bold 不在は regular で代替 (v1 制約)
pure (PNGFonts reg bold)
where
resolveFont index roleName mExplicit candidates = case mExplicit of
Just p -> do
ok <- doesFileExist p
if ok then loadOrDie p
else die ("明示指定の " ++ roleName ++ " フォントがありません: " ++ p)
Nothing -> case lookupCandidates index candidates of
Just p -> loadOrDie p
Nothing -> do
dirs <- fontSearchDirs
die $ unlines
[ "日本語対応 TTF フォントが見つかりません。"
, "探索ディレクトリ: " ++ intercalate ", " dirs
, "候補ファイル名: " ++ intercalate ", " candidates
, ".ttc (TrueType Collection) は非対応です。 .ttf を"
, "PNGConfig { pngFontPath = Just <path> } で明示指定してください。"
]
loadOrDie p = do
ef <- F.loadFontFile p
case ef of
Right font -> pure font
Left err -> die ("TTF の load に失敗 (" ++ p ++ "): " ++ err
++ " (※.ttc/OTF は非対応・.ttf のみ)")
die msg = errorWithoutStackTrace ("hgg-rasterific: " ++ msg)
-- | 探索ディレクトリ配下の .ttf を再帰列挙して (小文字ファイル名, path) の
-- 索引にする。 候補リスト順 (= 優先順) に索引を引く。
ttfIndex :: IO [(String, FilePath)]
ttfIndex = do
dirs <- fontSearchDirs
concat <$> mapM walk dirs
where
walk dir = do
ok <- doesDirectoryExist dir
if not ok then pure [] else do
entries <- listDirectory dir
fmap concat . mapM (entryOf dir) $ entries
entryOf dir e = do
let p = dir </> e
isDir <- doesDirectoryExist p
if isDir
then walk p
else pure [ (map toLower e, p)
| map toLower (takeExtension e) == ".ttf" ]
lookupCandidates :: [(String, FilePath)] -> [String] -> Maybe FilePath
lookupCandidates index = go
where
go [] = Nothing
go (c : cs) = case lookup c index of
Just p -> Just p
Nothing -> go cs
-- ===========================================================================
-- Primitive 解釈器
-- ===========================================================================
-- | Double → Float (Rasterific の座標は Float)。
f :: Double -> Float
f = realToFrac
v2 :: Point -> R.Point
v2 (Point x y) = R.V2 (f x) (f y)
-- | Primitive 列を順に描く。 PClipPush/PTransformPush は対応する Pop までを
-- **再帰グルーピング**して 'R.withClipping' / 'R.withTransformation' に入れる
-- (PDF backend の drawPrims と同型。 Rasterific の clip/transform も
-- scoped combinator なので同じ構図が自然に合う)。
-- 対応の取れない Pop は黙って無視 (SVG backend と同じ寛容さ)。
drawPrims :: PNGFonts -> [Primitive] -> R.Drawing PixelRGBA8 ()
drawPrims fonts = go
where
go [] = pure ()
go (PClipPush rect : rest) =
let (inner, after) = breakMatch isClipPush isClipPop rest
in do R.withClipping (R.fill (rectShape rect)) (go inner)
go after
go (PTransformPush tr : rest) =
let (inner, after) = breakMatch isTrPush isTrPop rest
in do R.withTransformation (transformOf tr) (go inner)
go after
go (PClipPop : rest) = go rest
go (PTransformPop : rest) = go rest
go (p : rest) = drawOne fonts 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)
-- | SVG 系 Transform → Rasterific Transformation (y 下向き同士なのでそのまま)。
transformOf :: Transform -> RTr.Transformation
transformOf (TranslateT dx dy) = RTr.translate (R.V2 (f dx) (f dy))
transformOf (ScaleT sx sy) = RTr.scale (f sx) (f sy)
-- | 単独 primitive の描画。 push/pop は 'drawPrims' が先に消費する。
drawOne :: PNGFonts -> Primitive -> R.Drawing PixelRGBA8 ()
drawOne _ (PLine a b ls) = drawLine a b ls
drawOne _ (PRect rect fs ms) = drawRect rect fs ms
drawOne _ (PCircle c rad fs ms _hover) = drawCircle c rad fs ms
drawOne _ (PPath segs fs ms) = drawPath segs fs ms
drawOne fonts (PText p s ts) = drawTextPrim fonts p s ts
drawOne _ _ = pure ()
-- | 線分: 色/幅/破線。 cap は SVG 既定 butt 相当 (CapStraight 0)。
drawLine :: Point -> Point -> LineStyle -> R.Drawing PixelRGBA8 ()
drawLine a b (LineStyle col w dash) =
R.withTexture (RT.uniformTexture (colorOf col 1.0)) $
strokeMaybeDashed dash (f w) (R.line (v2 a) (v2 b))
-- | dash 配列 (px) が空なら実線、 あれば dashedStroke。
strokeMaybeDashed :: [Double] -> Float
-> [R.Primitive] -> R.Drawing PixelRGBA8 ()
strokeMaybeDashed dash w geom = case dash of
[] -> R.stroke w joinStyle capStyle geom
ds -> R.dashedStroke (map f ds) w joinStyle capStyle geom
where
joinStyle = R.JoinMiter 0
capStyle = (R.CapStraight 0, R.CapStraight 0)
rectShape :: Rect -> [R.Primitive]
rectShape (Rect x y w h) = R.rectangle (R.V2 (f x) (f y)) (f w) (f h)
-- | 矩形: fill (+ 任意 stroke)。 'Rect' は左上基準 = Rasterific と同じ。
drawRect :: Rect -> FillStyle -> Maybe StrokeStyle -> R.Drawing PixelRGBA8 ()
drawRect rect fs ms = fillShape (rectShape rect) fs ms
-- | 円: fill (+ 任意 stroke)。 hover label は PNG では捨てる (PDF と同じ)。
drawCircle :: Point -> Double -> FillStyle -> Maybe StrokeStyle
-> R.Drawing PixelRGBA8 ()
drawCircle c rad fs ms = fillShape (R.circle (v2 c) (f rad)) fs ms
-- | Shape 共通: FillStyle (色 + opacity → alpha 合成) で塗り、
-- StrokeStyle があれば縁取る。
fillShape :: [R.Primitive] -> FillStyle -> Maybe StrokeStyle
-> R.Drawing PixelRGBA8 ()
fillShape shape (FillStyle col opa) ms = do
if col == "none"
then pure ()
else R.withTexture (RT.uniformTexture (colorOf col opa)) (R.fill shape)
case ms of
Nothing -> pure ()
Just (StrokeStyle sc sw) ->
R.withTexture (RT.uniformTexture (colorOf sc 1.0)) $
strokeMaybeDashed [] (f sw) shape
-- | パス: MoveTo 区切りで subpath ('R.Path') に分割して fill / stroke。
-- fill は全 subpath の primitive をまとめて 1 回 (= SVG の nonzero winding が
-- subpath 横断で効く)、 stroke は subpath ごと (= まとめると subpath 間に
-- 偽の接続 join が入る) に塗り分ける。
drawPath :: [PathSegment] -> FillStyle -> Maybe StrokeStyle
-> R.Drawing PixelRGBA8 ()
drawPath [] _ _ = pure ()
drawPath segs (FillStyle fc opa) ms = do
let subPrims = map R.pathToPrimitives (splitSubpaths segs)
if fc == "none"
then pure ()
else R.withTexture (RT.uniformTexture (colorOf fc opa)) $
R.fill (concat subPrims)
case ms of
Nothing -> pure ()
Just (StrokeStyle sc sw) ->
R.withTexture (RT.uniformTexture (colorOf sc 1.0)) $
mapM_ (strokeMaybeDashed [] (f sw)) subPrims
-- | 'PathSegment' 列 → 'R.Path' 列。 MoveTo で新 subpath、 ClosePath は
-- 現 subpath の close フラグ (以降に命令が続く稀ケースは同じ原点で新 subpath)。
-- 先頭が MoveTo でない場合は原点 (0,0) 開始 (PDF backend と同じ寛容さ)。
splitSubpaths :: [PathSegment] -> [R.Path]
splitSubpaths = go (R.V2 0 0) []
where
go origin acc [] = closeOff origin acc False []
go origin acc (MoveTo p : rest) =
closeOff origin acc False (go (v2 p) [] rest)
go origin acc (LineTo p : rest) =
go origin (R.PathLineTo (v2 p) : acc) rest
go origin acc (CurveTo c1 c2 p : rest) =
go origin (R.PathCubicBezierCurveTo (v2 c1) (v2 c2) (v2 p) : acc) rest
go origin acc (ClosePath : rest) =
closeOff origin acc True (go origin [] rest)
-- 空 subpath (連続 MoveTo 等) は捨てる
closeOff _ [] _ rest = rest
closeOff origin acc closed rest =
R.Path origin closed (reverse acc) : rest
-- | テキスト: 'R.printTextAt' (基準点 = baseline 開始 = SVG の text y と同義)。
-- anchor は 'F.stringBoundingBox' の advance width (_xMax 位置に格納・
-- FontyFruity ソース実測) で x 補正。 tsRotate (degrees CW・SVG 同義) は
-- y 下向き同士なので符号そのまま (PDF と対照的)、 rotate→translate の合成で
-- (x,y) 周りに回す。 tsSize は px → 'F.pixelSizeInPointAtDpi' で 96 dpi の
-- point に変換 ('R.renderDrawing' = 96 dpi 固定)。
-- v1 は family 非区別: tsWeight == "bold" のみ分岐、 italic は regular で代替。
drawTextPrim :: PNGFonts -> Point -> Text -> TextStyle
-> R.Drawing PixelRGBA8 ()
drawTextPrim fonts (Point x y) txt ts =
R.withTexture (RT.uniformTexture (colorOf (tsColor ts) 1.0)) $
if tsRotate ts == 0
then R.printTextAt font sizePt (R.V2 (f x + dx) (f y)) str
else R.withTransformation
-- Phase 50 A1: 内部 tsRotate は CCW 正 (canonical)。 Rasterific は y-down/CW ゆえ
-- ここで符号反転して device CW へ (唯一の変換点)。
(RTr.translate (R.V2 (f x) (f y))
<> RTr.rotate (f (negate (tsRotate ts)) * pi / 180))
(R.printTextAt font sizePt (R.V2 dx 0) str)
where
font = if tsWeight ts == "bold" then pfBold fonts else pfRegular fonts
sizePt = F.pixelSizeInPointAtDpi (f (max 1 (tsSize ts))) 96
str = T.unpack txt
advW = F._xMax (F.stringBoundingBox font 96 sizePt str)
dx = case tsAnchor ts of
AnchorStart -> 0
AnchorMiddle -> negate (advW / 2)
AnchorEnd -> negate advW
-- ===========================================================================
-- 色 (theme 色は全て "#rrggbb" hex。 named / 不正は黒 fallback)
-- ===========================================================================
-- | hex 色 + opacity → PixelRGBA8 (opacity は alpha 成分に畳む =
-- PDF v1 の opacity 問題は PNG では起きない)。
colorOf :: Text -> Double -> PixelRGBA8
colorOf t opa = case T.unpack t of
['#', r1, r2, g1, g2, b1, b2]
| all isHexDigit [r1, r2, g1, g2, b1, b2] ->
PixelRGBA8 (hex2 r1 r2) (hex2 g1 g2) (hex2 b1 b2) alpha
['#', r, g, b]
| all isHexDigit [r, g, b] -> PixelRGBA8 (hex2 r r) (hex2 g g) (hex2 b b) alpha
"white" -> PixelRGBA8 255 255 255 alpha
_ -> PixelRGBA8 0 0 0 alpha
where
alpha = fromIntegral (max 0 (min 255 (round (opa * 255) :: Int)))
hex2 a b = fromIntegral (digitToInt a * 16 + digitToInt b)