packages feed

nano-ui-sdl-0.1.0.0: lib/NanoUI/Sdl/Font/Search.hs

-- | Locate system font files by walking the standard font directories for the
-- current platform, without fontconfig. Each candidate file is matched
-- against the requested family name using a normalised-filename heuristic.
module NanoUI.Sdl.Font.Search
  ( searchFonts
  , searchFontFamilies
  , listFontFamilies
  ) where

import Control.Exception (IOException, catch)
import Data.Containers.ListUtils (nubOrd)
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Char (isDigit, isLower, isSpace, isUpper, toLower)
import Data.List (isInfixOf, minimumBy, sort, stripPrefix)
import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)
import Data.Ord (Down (..), comparing)
import qualified Data.Set as Set
import System.Directory (getHomeDirectory)
import System.Directory.Recursive (getFilesRecursive)
import System.Environment (lookupEnv)
import System.FilePath (takeBaseName, takeExtension, (</>))
import System.Info (os)
import System.IO.Unsafe (unsafePerformIO)

-- | Try each family name in order, returning the first font file that
-- matches.  Generic families like @monospace@ are expanded to a list of
-- concrete families first.
searchFonts :: [String] -> IO (Maybe FilePath)
searchFonts names = case concatMap families names of
  [] -> pure Nothing
  candidates -> do
    files <- fontStems
    pure (listToMaybe (mapMaybe (`bestMatch` files) candidates))
  where
    families name =
      let norm = normalize name
       in if null norm then [] else maybe [norm] (concatMap families) (expandGeneric norm)

-- | The file for each family that is installed, in the order asked, reading
-- the font directories once.
searchFontFamilies :: [String] -> IO [FilePath]
searchFontFamilies names = do
  files <- fontStems
  pure (nubOrd (mapMaybe (\name -> bestMatch (normalize name) files) names))

-- | Human-readable names for every installed font family, deduped and sorted.
-- Each name is a usable 'searchFonts' token: the same normalization is applied
-- to both the requested family and the file stem, so a listed name always
-- resolves back to (at least) the file it came from. Non-text faces (icons,
-- colour emoji) are included; callers that need sans families can filter the
-- result themselves.
listFontFamilies :: IO [String]
listFontFamilies = do
  files <- fontStems
  pure (Set.toAscList (Set.fromList (map (prettyFamily . takeBaseName . snd) files)))

-- | Filename stem -> display family. Everything from the first @-@ is treated
-- as style (\"Regular\", \"Bold Italic\", ...); camel case is split so
-- @NotoSansArabic@ reads as @Noto Sans Arabic@. Kept case-insensitively
-- compatible with 'normalize'.
prettyFamily :: String -> String
prettyFamily = separateCamel . stripStyle
  where
    stripStyle s = case break (== '-') s of
      (base, _) -> base
    separateCamel = go
      where
        go [] = []
        go (c : cs) = c : goTail c cs
        goTail _ [] = []
        goTail prev (c : cs)
          | isUpper c && (isLower prev || isDigit prev) = ' ' : c : goTail c cs
          | otherwise = c : goTail c cs

-- ---------------------------------------------------------------------------
-- Directory traversal

-- | Every font file under every standard font directory for this platform,
-- with its normalised name, normalised once for all the families matched
-- against it. User directories come first so that user-installed fonts win
-- over system ones; missing or unreadable roots are skipped. The directories
-- are walked once per process.
fontStems :: IO [(String, FilePath)]
fontStems =
  readIORef fontStemsRef >>= \case
    Just stems -> pure stems
    Nothing -> do
      roots <- defaultFontDirs
      files <- concat <$> mapM (fmap (sort . filter isFontFile) . filesBelow) roots
      let stems = map (\path -> (normalize (takeBaseName path), path)) files
      writeIORef fontStemsRef (Just stems)
      pure stems

{-# NOINLINE fontStemsRef #-}
fontStemsRef :: IORef (Maybe [(String, FilePath)])
fontStemsRef = unsafePerformIO (newIORef Nothing)

filesBelow :: FilePath -> IO [FilePath]
filesBelow root =
  getFilesRecursive root `catch` \(_ :: IOException) -> pure []

defaultFontDirs :: IO [FilePath]
defaultFontDirs =
  case os of
    "darwin" -> macDirs
    "mingw32" -> winDirs
    _ -> linuxDirs
  where
    linuxDirs :: IO [FilePath]
    linuxDirs = do
      home <- getHomeDirectory
      pure
        [ home </> ".local/share/fonts"
        , "/usr/local/share/fonts"
        , "/usr/share/fonts"
        ]

    macDirs :: IO [FilePath]
    macDirs = do
      home <- getHomeDirectory
      pure
        [ home </> "Library/Fonts"
        , "/Library/Fonts"
        , "/System/Library/Fonts"
        ]

    winDirs :: IO [FilePath]
    winDirs = do
      mRoot <- lookupEnv "SystemRoot"
      let systemDir = fromMaybe "C:\\Windows" mRoot </> "Fonts"
      mLocal <- lookupEnv "LOCALAPPDATA"
      let userDirs =
            maybe [] (\l -> [l </> "Microsoft" </> "Windows" </> "Fonts"]) mLocal
      pure (userDirs ++ [systemDir])

isFontFile :: FilePath -> Bool
isFontFile path =
  map toLower (takeExtension path) `elem` [".ttf", ".otf", ".ttc", ".otc"]

-- ---------------------------------------------------------------------------
-- Family matching

-- | Pick the highest-scoring file for @norm@ (a normalised family name).
bestMatch :: String -> [(String, FilePath)] -> Maybe FilePath
bestMatch norm files =
  case [(score, path) | (stem, path) <- files, Just score <- [maximum (Nothing : map (`matchScore` stem) candidates)]] of
    [] -> Nothing
    scored ->
      -- minimumBy keeps the first tie; descending scores prefer the best face.
      let (_, best) = minimumBy (comparing (Down . fst)) scored
       in Just best
  where
    candidates = norm : familyAliases norm

matchScore :: String -> String -> Maybe Int
matchScore "" _ = Nothing
matchScore norm stem
  | stem == norm = Just 100
  | otherwise =
      case stripPrefix norm stem of
        Nothing -> Nothing
        Just t
          | t `elem` regularTails -> Just 90
          | t `elem` otherTails -> Just 70
          | "variable" `isInfixOf` t -> Just 20
          | otherwise -> Just 60

-- | Style tails that indicate the regular weight of a family.
regularTails :: [String]
regularTails = ["regular", "r", "normal", "medium", "text"]

-- | Style tails for non-regular weights; still worth preferring over an
-- unrelated font, but a regular (or exact) match wins.
otherTails :: [String]
otherTails =
  [ "bold"
  , "italic"
  , "light"
  , "semibold"
  , "semibolditalic"
  , "extrabold"
  , "thin"
  , "black"
  , "regularitalic"
  , "bolditalic"
  , "mediumitalic"
  , "oblique"
  ]

-- | Windows ships @Consolas@ as @consola.ttf@ and @Courier New@ as
-- @cour.ttf@, so those families need filename aliases that prefix-match
-- differently than their family names.
familyAliases :: String -> [String]
familyAliases "consolas" = ["consola"]
familyAliases "couriernew" = ["cour"]
familyAliases _ = []

-- | Expand a generic CSS family into concrete families to search in order.
expandGeneric :: String -> Maybe [String]
expandGeneric "monospace" =
  Just
    [ "DejaVu Sans Mono"
    , "Liberation Mono"
    , "Ubuntu Mono"
    , "Noto Sans Mono"
    , "Consolas"
    , "Courier New"
    ]
expandGeneric "sansserif" =
  Just
    [ "DejaVu Sans"
    , "Liberation Sans"
    , "Noto Sans"
    , "Open Sans"
    , "Helvetica Neue"
    ]
expandGeneric "serif" =
  Just
    [ "DejaVu Serif"
    , "Liberation Serif"
    , "Noto Serif"
    , "Times New Roman"
    ]
expandGeneric _ = Nothing

-- | Fold case, drop whitespace and separators, so family \/ file names can be
-- compared loosely ("DejaVu Sans Mono" vs @DejaVuSansMono.ttf@).
normalize :: String -> String
normalize =
  map toLower
    . filter (\c -> not (isSpace c) && c /= '-' && c /= '_')