ktx-font-0.2.0.0: test/Spec.hs
module Main where
import Control.Monad (unless)
import Data.Foldable (for_)
import Data.List (isSuffixOf)
import Data.Text qualified as Text
import Data.Traversable (for)
import Data.Vector.Storable qualified as Storable
import System.Directory (doesFileExist, listDirectory)
import System.FilePath ((</>))
import Test.Tasty (TestTree, defaultMain, testGroup, withResource)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))
import Codec.Ktx2.Font (Bundle(..))
import Codec.Ktx2.Font qualified as Font
import Codec.Ktx2.Font.Layout qualified as Layout
import Codec.Ktx2.Font.Shaping (Box(..), Compact(..), PlacedGlyph(..))
import Codec.Ktx2.Font.Shaping qualified as Shaping
{- | Font bundles built from @assets.yaml@.
Regenerate them with @stack exec ktx-build@ when the assets or the bundle format change.
-}
bundlesPath :: FilePath
bundlesPath = ".." </> "assets" </> "fonts"
main :: IO ()
main = do
entries <- listDirectory bundlesPath
let sources = [bundlesPath </> entry | entry <- entries, ".ktxf" `isSuffixOf` entry]
unless (null sources) $
putStrLn $ "Found " <> show (length sources) <> " bundles in " <> bundlesPath
defaultMain $
testGroup "ktx-font"
[ testCase "bundles are built" $
assertBool (bundlesPath <> " has no .ktxf bundles, run the ktx-build asset build") $
not (null sources)
, testGroup "bundles" $ map bundleTests sources
, withStack sources \getStack ->
testGroup "text"
[ shapingTests (fmap fst getStack)
, layoutTests getStack
]
, testCase "demo bundles are built" $
doesFileExist ligatureBundle >>=
assertBool (ligatureBundle <> " is missing, run the ktx-build asset build")
, withStack [ligatureBundle] ligatureTests
]
bundleTests :: FilePath -> TestTree
bundleTests source =
withResource (Font.loadBundleFile source) Font.freeBundle \getBundle ->
testGroup source
[ testCase "carries an atlas" do
Bundle{atlas} <- getBundle
assertBool "no glyphs in the atlas" $
not (Storable.null atlas.glyphs)
Storable.length atlas.glyphs @?= Storable.length atlas.planes
, testCase "atlas boxes are normalized to UV" do
Bundle{atlas} <- getBundle
Storable.forM_ atlas.glyphs \box ->
assertBool ("glyph box outside the 0..1 UV range: " <> show box) $
inUnitRange box
]
where
inUnitRange Box{x, y, w, h} =
x >= 0 && y >= 0 && x + w <= 1.001 && y + h <= 1.001
{- | Load every bundle into a single shaping context, the way a renderer would.
The stack context doesn't keep the font memory alive, so the bundles have to outlive it.
-}
withStack :: [FilePath] -> (IO (Font.StackContext (), Layout.TextStyle) -> TestTree) -> TestTree
withStack sources withCtx =
withResource acquire release $ withCtx . fmap snd
where
acquire = do
bundles <- for sources Font.loadBundleFile
ctx <- Font.createStackContext bundles
style <- Layout.bundleStyle ctx (last bundles)
pure (bundles, (ctx, style))
release (bundles, (ctx, _style)) = do
Font.destroyStackContext ctx
for_ bundles Font.freeBundle
shapingTests :: IO (Font.StackContext ()) -> TestTree
shapingTests getCtx =
testGroup "shaping"
[ testCase "shapes the samples" do
ctx <- getCtx
for_ samples \sample -> do
runs <- Shaping.shapeText (Shaping.initialCursorDown 2.0) ctx sample
assertBool ("nothing shaped for " <> show sample) $
not (null runs)
assertBool ("no glyphs placed for " <> show sample) $
not (null $ concatMap snd runs)
, testCase "cursorDown advances the lines downwards" do
ctx <- getCtx
(top, bottom) <- twoLines (Shaping.initialCursorDown 2.0) ctx
assertBool ("second line is not below the first: " <> show (top, bottom)) $
bottom.plane.y < top.plane.y
, testCase "cursorUp advances the lines upwards" do
ctx <- getCtx
(first_, second_) <- twoLines (Shaping.initialCursorUp 2.0) ctx
assertBool ("second line is not above the first: " <> show (first_, second_)) $
second_.plane.y > first_.plane.y
, testCase "the newline advance matches the line height" do
ctx <- getCtx
(top, bottom) <- twoLines (Shaping.initialCursorDown 2.0) ctx
-- Same glyph on both lines, so the planes differ by exactly one line.
let gap = top.plane.y - bottom.plane.y
assertBool ("unexpected line gap: " <> show gap) $
abs (gap - 2.0) < 0.001
]
where
-- The same glyph on two lines, so the placements are comparable.
-- The newline itself doesn't get placed.
twoLines cur ctx = do
runs <- Shaping.shapeText cur ctx "H\nH"
case concatMap snd runs of
[top, bottom] ->
pure (top, bottom)
placed ->
assertFailure $ "expected two placed glyphs, got " <> show placed
samples =
[ "Test text, please shape."
, "Multiple\nlines\nwith an empty one:\n\nand a tail."
, "Punctuation: ,.!?;:'\"()[]{}"
]
layoutTests :: IO (Font.StackContext (), Layout.TextStyle) -> TestTree
layoutTests getStack =
testGroup "layout"
[ testCase "wide enough text stays on one line, matching plain shaping" do
(ctx, style) <- getStack
lns <- layout ctx style 1e6 "Sphinx of black quartz, judge my vow."
map (.ended) lns @?= [Layout.Finished]
shaped <- Shaping.shapeText cursor ctx "Sphinx of black quartz, judge my vow."
planesOf (Layout.placedRuns lns) `closeTo` planesOf shaped
, testCase "narrow text wraps under the maximum width" do
(ctx, style) <- getStack
lns <- layout ctx style 3.0 "one two three four five"
assertBool "text did not wrap" $ length lns > 1
for_ lns \line ->
unless (line.ended == Layout.Overflowed) $
assertBool ("line too wide: " <> show (line.text, line.width)) $
line.width <= 3.001
, testCase "hard breaks match the legacy newline behavior" do
(ctx, style) <- getStack
lns <- layout ctx style 1e6 "H\nH"
map (.ended) lns @?= [Layout.HardBroken, Layout.Finished]
case planesOf (Layout.placedRuns lns) of
[top, bottom] ->
assertBool ("unexpected line gap: " <> show (top, bottom)) $
abs (top.y - bottom.y - 2.0) < 0.001
planes ->
assertFailure $ "expected two placed glyphs, got " <> show planes
, testCase "hard breaks don't accumulate drift" do
(ctx, style) <- getStack
lns <- layout ctx style 1e6 "H\nH\nH\nH"
let firsts = [p.x | line <- lns, p : _ <- [planesOf line.runs]]
length firsts @?= 4
assertBool ("line starts drift: " <> show firsts) $
maximum firsts - minimum firsts < 0.001
, testCase "empty lines are preserved" do
(ctx, style) <- getStack
lns <- layout ctx style 1e6 "a\n\nb"
map (.ended) lns @?= [Layout.HardBroken, Layout.HardBroken, Layout.Finished]
map (null . (.runs)) lns @?= [False, True, False]
, testCase "right alignment pushes the origin to the edge" do
(ctx, style) <- getStack
lns <- Layout.layoutTextWith
Layout.LayoutOptions{cursor, strategy = Layout.Greedy, align = Layout.AlignRight}
ctx style 10 "H"
case lns of
[line] -> do
let (x0, _y0) = line.origin
assertBool ("origin not at the right edge: " <> show (x0, line.width)) $
abs (x0 - (10 - line.width)) < 0.001
_ ->
assertFailure $ "expected one line, got " <> show (length lns)
, testCase "one shaping places at any width" do
(ctx, style) <- getStack
shaped <- Layout.shapeText ctx style "one two three four five"
let
place w = Layout.placeText Layout.LayoutOptions{cursor, strategy = Layout.Greedy, align = Layout.AlignLeft} w shaped
wide = place 1e6
narrow = place 3.0
length wide @?= 1
assertBool "narrow placement did not wrap" $ length narrow > 1
for_ (zip [0 :: Int ..] narrow) \(i, line) -> do
let (x0, y0) = line.origin
assertBool ("line " <> show i <> " is not on its own row: " <> show y0) $
abs (y0 + fromIntegral i * 2.0) < 0.001
plain <- Shaping.shapeText cursor{Shaping.curX = x0, Shaping.curY = y0} ctx line.text
planesOf line.runs `closeTo` planesOf plain
, testCase "soft hyphens break with a visible hyphen" do
(ctx, style) <- getStack
shaped <- Layout.shapeText ctx style "hyphen\173ation"
w <- Layout.measureText ctx style "hyphenation"
let
lns = Layout.placeText Layout.LayoutOptions{cursor, strategy = Layout.Greedy, align = Layout.AlignLeft} (w * 0.75) shaped
map (.ended) lns @?= [Layout.Hyphenated, Layout.Finished]
case lns of
[top, bottom] -> do
top.text @?= "hyphen-"
bottom.text @?= "ation"
let glyphs = concatMap snd top.runs
assertBool "no glyphs on the hyphenated line" $ not (null glyphs)
(last glyphs).codepoint @?= '-'
assertBool ("hyphen not at the line end: " <> show (map (.plane) glyphs)) $
(last glyphs).plane.x >= maximum (map (.plane.x) (init glyphs))
_ ->
assertFailure $ "expected two lines, got " <> show (length lns)
, testCase "measuring and shrinkwrapping" do
(ctx, style) <- getStack
w <- Layout.measureText ctx style "H"
assertBool ("H has no width: " <> show w) $ w > 0
shrunk <- Layout.shrinkwrapText ctx style 3.0 "one two three four five"
assertBool ("shrinkwrap exceeded the limit: " <> show shrunk) $ shrunk <= 3.001
]
where
layout = Layout.layoutText cursor
{- | Ligature tests need a font with real multi-codepoint glyphs.
MapleMono renders its coding ligatures as per-glyph contextual alternates,
so its glyphs never span codepoints. NotoSans has a proper @ffi@ ligature.
-}
ligatureBundle :: FilePath
ligatureBundle = ".." </> "assets" </> "demo" </> "NotoSans-Regular.ktxf"
ligatureTests :: IO (Font.StackContext (), Layout.TextStyle) -> TestTree
ligatureTests getStack =
testGroup "ligatures"
[ testCase "the bundle shapes the ligature as one glyph" do
(ctx, style) <- getStack
shaped <- Layout.shapeText ctx style ligature
let whole = glyphsOf (place shaped 1e6)
assertBool ("no ligature shaped for " <> show ligature <> ": " <> show (map (.codepoint) whole)) $
length whole < Text.length ligature
, testCase "emergency breaks keep the ligature whole" do
(ctx, style) <- getStack
shaped <- Layout.shapeText ctx style ligature
w <- Layout.measureText ctx style ligature
let whole = glyphsOf (place shaped 1e6)
for_ [w / 2, w * 0.9] \width -> do
let lns = place shaped width
map (.text) lns @?= [ligature]
map (.ended) lns @?= [Layout.Overflowed]
map (.plane) (glyphsOf lns) `closeTo` map (.plane) whole
, testCase "wrapping after the ligature keeps the following text" do
(ctx, style) <- getStack
shaped <- Layout.shapeText ctx style (ligature <> "ab")
w <- max <$> Layout.measureText ctx style ligature <*> Layout.measureText ctx style "ab"
let lns = place shaped w
map (.text) lns @?= [ligature, "ab"]
for_ lns \line -> do
let (x0, y0) = line.origin
plain <- Shaping.shapeText cursor{Shaping.curX = x0, Shaping.curY = y0} ctx line.text
planesOf line.runs `closeTo` planesOf plain
]
where
ligature = "ffi"
place shaped width = Layout.placeText Layout.LayoutOptions{cursor, strategy = Layout.Greedy, align = Layout.AlignLeft} width shaped
glyphsOf lns = concatMap snd (Layout.placedRuns lns)
cursor :: Shaping.Cursor
cursor = Shaping.initialCursorDown 2.0
planesOf :: [Shaping.PlacedRun] -> [Box]
planesOf runs = [g.plane | (_font, glyphs) <- runs, g <- glyphs]
closeTo :: [Box] -> [Box] -> IO ()
closeTo actual expected = do
length actual @?= length expected
for_ (zip actual expected) \(a, e) ->
assertBool ("planes differ: " <> show (a, e)) $
all (< 0.001) [abs (a.x - e.x), abs (a.y - e.y), abs (a.w - e.w), abs (a.h - e.h)]