M .golden/hardBreaksLTRParagraph/golden => .golden/hardBreaksLTRParagraph/golden +5 -3
@@ 1,4 1,4 @@
-ParagraphLayout {paragraphRect = Rect {x_origin = 0, y_origin = 0, x_size = 4305, y_size = -8968}, spanLayouts = [
+ParagraphLayout {paragraphRect = Rect {x_origin = 0, y_origin = 0, x_size = 4305, y_size = -10089}, spanLayouts = [
     SpanLayout [Fragment {fragmentRect = Rect {x_origin = 0, y_origin = 0, x_size = 1563, y_size = -1121}, fragmentPen = (0,-932), fragmentGlyphs =
         [(GlyphInfo {codepoint = 77, cluster = 1, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 262, y_advance = 0, x_offset = 0, y_offset = 0}),
         (GlyphInfo {codepoint = 77, cluster = 2, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 262, y_advance = 0, x_offset = 0, y_offset = 0}),
@@ 42,13 42,15 @@ ParagraphLayout {paragraphRect = Rect {x_origin = 0, y_origin = 0, x_size = 4305
         (GlyphInfo {codepoint = 77, cluster = 40, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 262, y_advance = 0, x_offset = 0, y_offset = 0}),
         (GlyphInfo {codepoint = 77, cluster = 41, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 262, y_advance = 0, x_offset = 0, y_offset = 0}),
         (GlyphInfo {codepoint = 77, cluster = 42, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 253, y_advance = 0, x_offset = 0, y_offset = 0})]
-    }, Fragment {fragmentRect = Rect {x_origin = 0, y_origin = -6726, x_size = 3675, y_size = -1121}, fragmentPen = (0,-932), fragmentGlyphs =
+    }, Fragment {fragmentRect = Rect {x_origin = 0, y_origin = -6726, x_size = 0, y_size = -1121}, fragmentPen = (0,-932), fragmentGlyphs =
+        []
+    }, Fragment {fragmentRect = Rect {x_origin = 0, y_origin = -7847, x_size = 3675, y_size = -1121}, fragmentPen = (0,-932), fragmentGlyphs =
         [(GlyphInfo {codepoint = 80, cluster = 45, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 861, y_advance = 0, x_offset = 0, y_offset = 0}),
         (GlyphInfo {codepoint = 80, cluster = 46, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 861, y_advance = 0, x_offset = 0, y_offset = 0}),
         (GlyphInfo {codepoint = 3, cluster = 47, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 231, y_advance = 0, x_offset = 0, y_offset = 0}),
         (GlyphInfo {codepoint = 80, cluster = 48, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 861, y_advance = 0, x_offset = 0, y_offset = 0}),
         (GlyphInfo {codepoint = 80, cluster = 49, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 861, y_advance = 0, x_offset = 0, y_offset = 0})]
-    }, Fragment {fragmentRect = Rect {x_origin = 0, y_origin = -7847, x_size = 1722, y_size = -1121}, fragmentPen = (0,-932), fragmentGlyphs =
+    }, Fragment {fragmentRect = Rect {x_origin = 0, y_origin = -8968, x_size = 1722, y_size = -1121}, fragmentPen = (0,-932), fragmentGlyphs =
         [(GlyphInfo {codepoint = 80, cluster = 51, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 861, y_advance = 0, x_offset = 0, y_offset = 0}),
         (GlyphInfo {codepoint = 80, cluster = 52, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 861, y_advance = 0, x_offset = 0, y_offset = 0})]
     }]
 
M .golden/hardBreaksRTLParagraph/golden => .golden/hardBreaksRTLParagraph/golden +5 -3
@@ 1,4 1,4 @@
-ParagraphLayout {paragraphRect = Rect {x_origin = 0, y_origin = 0, x_size = 5852, y_size = -12000}, spanLayouts = [
+ParagraphLayout {paragraphRect = Rect {x_origin = 0, y_origin = 0, x_size = 5852, y_size = -13500}, spanLayouts = [
     SpanLayout [Fragment {fragmentRect = Rect {x_origin = 0, y_origin = 0, x_size = 2808, y_size = -1500}, fragmentPen = (0,-1085), fragmentGlyphs =
         [(GlyphInfo {codepoint = 642, cluster = 11, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 468, y_advance = 0, x_offset = 0, y_offset = 0}),
         (GlyphInfo {codepoint = 642, cluster = 9, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 468, y_advance = 0, x_offset = 0, y_offset = 0}),
@@ 43,13 43,15 @@ ParagraphLayout {paragraphRect = Rect {x_origin = 0, y_origin = 0, x_size = 5852
         (GlyphInfo {codepoint = 642, cluster = 73, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 468, y_advance = 0, x_offset = 0, y_offset = 0}),
         (GlyphInfo {codepoint = 642, cluster = 71, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 468, y_advance = 0, x_offset = 0, y_offset = 0}),
         (GlyphInfo {codepoint = 642, cluster = 69, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 468, y_advance = 0, x_offset = 0, y_offset = 0})]
-    }, Fragment {fragmentRect = Rect {x_origin = 0, y_origin = -9000, x_size = 4156, y_size = -1500}, fragmentPen = (0,-1085), fragmentGlyphs =
+    }, Fragment {fragmentRect = Rect {x_origin = 0, y_origin = -9000, x_size = 0, y_size = -1500}, fragmentPen = (0,-1085), fragmentGlyphs =
+        []
+    }, Fragment {fragmentRect = Rect {x_origin = 0, y_origin = -10500, x_size = 4156, y_size = -1500}, fragmentPen = (0,-1085), fragmentGlyphs =
         [(GlyphInfo {codepoint = 687, cluster = 90, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 1211, y_advance = 0, x_offset = 0, y_offset = 0}),
         (GlyphInfo {codepoint = 370, cluster = 88, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 749, y_advance = 0, x_offset = 0, y_offset = 0}),
         (GlyphInfo {codepoint = 3, cluster = 87, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 236, y_advance = 0, x_offset = 0, y_offset = 0}),
         (GlyphInfo {codepoint = 687, cluster = 85, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 1211, y_advance = 0, x_offset = 0, y_offset = 0}),
         (GlyphInfo {codepoint = 370, cluster = 83, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 749, y_advance = 0, x_offset = 0, y_offset = 0})]
-    }, Fragment {fragmentRect = Rect {x_origin = 0, y_origin = -10500, x_size = 1960, y_size = -1500}, fragmentPen = (0,-1085), fragmentGlyphs =
+    }, Fragment {fragmentRect = Rect {x_origin = 0, y_origin = -12000, x_size = 1960, y_size = -1500}, fragmentPen = (0,-1085), fragmentGlyphs =
         [(GlyphInfo {codepoint = 687, cluster = 95, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 1211, y_advance = 0, x_offset = 0, y_offset = 0}),
         (GlyphInfo {codepoint = 370, cluster = 93, unsafeToBreak = False, unsafeToConcat = False, safeToInsertTatweel = False},GlyphPos {x_advance = 749, y_advance = 0, x_offset = 0, y_offset = 0})]
     }]
 
M src/Data/Text/ParagraphLayout/Internal/Plain.hs => src/Data/Text/ParagraphLayout/Internal/Plain.hs +21 -11
@@ 118,9 118,9 @@ positionLineH :: Int32 -> [WithSpan PF.ProtoFragment] ->
     (Int32, [WithSpan Fragment])
 positionLineH originY pfs = (nextY, frags)
     where
-        -- A line with no glyphs will be considered to have zero height.
+        -- A line with no fragments will be considered to have zero height.
         -- This can happen when line breaking produces a line that contains
-        -- onls spaces.
+        -- only spaces.
         nextY = if null rects then originY else maximum $ map y_min rects
         rects = map (\(WithSpan _ r) -> fragmentRect r) frags
         frags = snd $ mapAccumL (positionFragmentH originY) originX pfs
@@ 158,28 158,32 @@ layoutAndWrapRunsH maxWidth runs = NonEmpty.head $ validLayouts
         validLayouts = dropWhile1 tooLong layouts
         tooLong (pfs, _) = totalAdvances pfs > maxWidth
         layouts = NonEmpty.map layoutFst splits
-        layoutFst (runs1, runs2) = (layout runs1, runs2)
-        layout runs1 = layoutRunsH $ trimTextsEnd isEndSpace runs1
+        layoutFst (runs1, runs2) = (layoutRunsH runs1, runs2)
         -- TODO: Consider optimising.
         --       We do not need to look for soft breaks further than the
         --       shortest hard break.
-        -- TODO: Add a "strut" for empty lines.
         splits = hardSplit runs :| softSplits runs
 
 -- | Treat a list of runs as a contiguous sequence, and split them into two
--- lists so that the first list contains as much of the input text as possible
--- without crossing a hard line break (typically after a newline character).
+-- lists so that the first list contains as many non-whitespace characters as
+-- possible without crossing a hard line break (typically after a newline
+-- character).
+--
+-- If the input is non-empty and starts with a hard line break, then the first
+-- output list will contain a run of zero characters. This can be used to
+-- correctly size an empty line.
 --
 -- If there is no hard line break in the input, the first output list will
 -- contain the whole input, and the second output list will be empty.
 hardSplit :: [WithSpan Run] -> ([WithSpan Run], [WithSpan Run])
-hardSplit runs = NonEmpty.last $ splits
+hardSplit runs = trimFst $ NonEmpty.last $ splits
     where
+        trimFst (runs1, runs2) = (trim runs1, runs2)
+        trim = trimTextsEndPreserve isEndSpace . trimTextsEndPreserve isNewline
         -- TODO: Consider optimising.
         --       We do not need to look for any line breaks further than the
         --       shortest hard break.
-        splits = noSplit :| map trimFst hSplits
-        trimFst (runs1, runs2) = (trimTextsEnd isNewline runs1, runs2)
+        splits = noSplit :| hSplits
         noSplit = (runs, [])
         hSplits = -- from longest to shortest
             splitTextsBy (map fst . filter isHard . runLineBreaks) runs
@@ 190,12 194,18 @@ hardSplit runs = NonEmpty.last $ splits
 -- using soft line break opportunities (typically after words) and then
 -- using character boundaries.
 --
+-- Runs of zero characters will not be created. If line breaking would result
+-- in a line that consists entirely of whitespace, this whitespace will be
+-- skipped, so an empty line is not created.
+--
 -- The results in the form (prefix, suffix) will be ordered so that items
 -- closer to the start of the list are preferred for line breaking, but without
 -- considering overflows.
 softSplits :: [WithSpan Run] -> [([WithSpan Run], [WithSpan Run])]
-softSplits runs = splits
+softSplits runs = map trimFst splits
     where
+        trimFst (runs1, runs2) = (trim runs1, runs2)
+        trim = trimTextsEnd isEndSpace
         splits = lSplits ++ cSplits
         lSplits = splitTextsBy (map fst . runLineBreaks) runs
         -- TODO: Consider optimising.
 
M src/Data/Text/ParagraphLayout/Internal/TextContainer.hs => src/Data/Text/ParagraphLayout/Internal/TextContainer.hs +20 -0
@@ 6,6 6,7 @@ module Data.Text.ParagraphLayout.Internal.TextContainer
     ,splitTextAt8
     ,splitTextsBy
     ,trimTextsEnd
+    ,trimTextsEndPreserve
     )
 where
 
@@ 77,9 78,24 @@ collapse (tc :| tcs)
 
 -- | Treat a list of text containers as a contiguous sequence,
 -- and remove a suffix of characters that match the given predicate.
+--
+-- Empty text containers are removed from the output, so the result may
+-- potentially be an empty list.
 trimTextsEnd :: SeparableTextContainer a => (Char -> Bool) -> [a] -> [a]
 trimTextsEnd p tcs = trimTextsEnd' p (reverse tcs)
 
+-- | Treat a list of text containers as a contiguous sequence,
+-- and remove a suffix of characters that match the given predicate.
+--
+-- Empty text containers are removed from the output except the first one,
+-- which is instead truncated to zero length.
+trimTextsEndPreserve ::
+    SeparableTextContainer a => (Char -> Bool) -> [a] -> [a]
+trimTextsEndPreserve _ [] = []
+trimTextsEndPreserve p ins@(in1:_) = case trimTextsEnd' p (reverse ins) of
+    [] -> [truncateText in1]
+    out -> out
+
 trimTextsEnd' :: SeparableTextContainer a => (Char -> Bool) -> [a] -> [a]
 trimTextsEnd' _ [] = []
 trimTextsEnd' p (tc:tcs)
@@ 87,3 103,7 @@ trimTextsEnd' p (tc:tcs)
     | otherwise = reverse $ trimmed:tcs
     where
         trimmed = dropWhileEnd p tc
+
+-- | Discard all text from the container by creating a prefix of length 0.
+truncateText :: SeparableTextContainer a => a -> a
+truncateText tc = fst $ splitTextAt8 0 tc
 
M test/Data/Text/ParagraphLayout/Internal/BreakSpec.hs => test/Data/Text/ParagraphLayout/Internal/BreakSpec.hs +7 -0
@@ 37,6 37,13 @@ spec = do
                     ,(0, BreakStatus.Soft)
                     ]
 
+            it "finds hard break after each of newlines" $
+                b "en" (pack "hello\n\nworld") `shouldBe`
+                    [(7, BreakStatus.Hard)
+                    ,(6, BreakStatus.Hard)
+                    ,(0, BreakStatus.Soft)
+                    ]
+
             it "finds soft breaks after spaces and tabs" $
                 b "en" (pack "a few\twords") `shouldBe`
                     [(6, BreakStatus.Soft)