hpdft-0.4.7.1: src/PDF/Diff.hs
{-# LANGUAGE OverloadedStrings #-}
{-|
Module : PDF.Diff
Description : Paragraph-level text diff between two PDF documents
License : MIT
Compare two opened documents page by page using the same paragraph layout as
'PDF.Page.pageParagraphs'. Emits 'TextChange' records (and optionally a
'PageCountMismatch' when page counts differ). Paragraph equality ignores
whitespace by default.
@example
import PDF.Diff (compareDocuments)
import PDF.Layout (defaultLayoutOptions)
changes <- compareDocuments defaultLayoutOptions docA docB
-}
module PDF.Diff
( TextChange(..)
, DiffPipeline(..)
, DiffColor(..)
, compareDocuments
, compareDocumentsWith
, diffParagraphs
, legacyTextParagraphs
, alignChangeSpans
, renderUnifiedDiff
) where
import PDF.Document (Document)
import PDF.Error (PdfResult)
import PDF.Layout (LayoutOptions)
import PDF.Page (pageCount, pageRefAt, pageParagraphs)
import PDF.Text (pageLegacyText)
import Data.Char (isSpace)
import Data.List (intercalate, nub, sort, sortOn)
import qualified Data.Text as T
data TextChange
= TextChange
{ changePageA :: !(Maybe Int)
, changePageB :: !(Maybe Int)
, changeParaA :: !(Maybe Int)
, changeParaB :: !(Maybe Int)
, changeOld :: !T.Text
, changeNew :: !T.Text
}
| PageCountMismatch
{ pagesA :: !Int
, pagesB :: !Int
}
deriving (Eq, Show)
data DiffPipeline
= DiffGeom !LayoutOptions
| DiffLegacy
-- | Whether human-readable unified diff should include ANSI colors.
data DiffColor
= DiffColorOff
| DiffColorAnsi
deriving (Eq, Show)
-- | Paragraph-level diff using geometry layout (default).
compareDocuments :: LayoutOptions -> Document -> Document -> PdfResult [TextChange]
compareDocuments opts = compareDocumentsWith (DiffGeom opts)
-- | Paragraph-level diff; pipeline selects geometry vs legacy stream-order text.
compareDocumentsWith :: DiffPipeline -> Document -> Document -> PdfResult [TextChange]
compareDocumentsWith pipeline docA docB = do
nA <- pageCount docA
nB <- pageCount docB
let countChange =
if nA /= nB
then [PageCountMismatch {pagesA = nA, pagesB = nB}]
else []
aligned <- mapM (diffPagePair pipeline docA docB) [1 .. min nA nB]
extraA <- mapM (onlyInA pipeline docA) [min nA nB + 1 .. nA]
extraB <- mapM (onlyInB pipeline docB) [min nA nB + 1 .. nB]
return (countChange ++ concat aligned ++ concat extraA ++ concat extraB)
diffPagePair :: DiffPipeline -> Document -> Document -> Int -> PdfResult [TextChange]
diffPagePair pipeline docA docB page = do
refA <- pageRefAt docA page
refB <- pageRefAt docB page
parasA <- pageParagraphsFor pipeline docA refA
parasB <- pageParagraphsFor pipeline docB refB
return (diffParagraphsOnPage page parasA parasB)
pageParagraphsFor :: DiffPipeline -> Document -> Int -> PdfResult [T.Text]
pageParagraphsFor (DiffGeom opts) doc ref = pageParagraphs doc ref opts
pageParagraphsFor DiffLegacy doc ref = do
txt <- pageLegacyText doc ref
return (legacyTextParagraphs txt)
onlyInA :: DiffPipeline -> Document -> Int -> PdfResult [TextChange]
onlyInA pipeline doc page = do
ref <- pageRefAt doc page
paras <- pageParagraphsFor pipeline doc ref
return
[ TextChange
{ changePageA = Just page
, changePageB = Nothing
, changeParaA = Just idx
, changeParaB = Nothing
, changeOld = txt
, changeNew = T.empty
}
| (idx, txt) <- zip [0 ..] paras
]
onlyInB :: DiffPipeline -> Document -> Int -> PdfResult [TextChange]
onlyInB pipeline doc page = do
ref <- pageRefAt doc page
paras <- pageParagraphsFor pipeline doc ref
return
[ TextChange
{ changePageA = Nothing
, changePageB = Just page
, changeParaA = Nothing
, changeParaB = Just idx
, changeOld = T.empty
, changeNew = txt
}
| (idx, txt) <- zip [0 ..] paras
]
-- | Split legacy page text into paragraph-sized units (blank-line separated).
legacyTextParagraphs :: T.Text -> [T.Text]
legacyTextParagraphs t =
let chunks = filter (not . T.null) $ map collapseParaWS $ T.splitOn "\n\n" t
in if null chunks && not (T.null (collapseParaWS t))
then [collapseParaWS t]
else chunks
diffParagraphsOnPage :: Int -> [T.Text] -> [T.Text] -> [TextChange]
diffParagraphsOnPage page parasA parasB =
map attachPage (diffParagraphs parasA parasB)
where
attachPage TextChange{changeParaA = pa, changeParaB = pb, changeOld = old, changeNew = new} =
TextChange (Just page) (Just page) pa pb old new
attachPage other = other
-- | Paragraph LCS diff without page numbers (for unit tests).
--
-- When consecutive paragraphs both change, prefer a paired replace over
-- independent delete+insert. Otherwise LCS + adjacent merge can cross-wire
-- neighbors (e.g. old para N vs new para N-1).
diffParagraphs :: [T.Text] -> [T.Text] -> [TextChange]
diffParagraphs parasA parasB =
sortChanges $ mergeReplaceChanges $ go (length normA) (length normB) []
where
normA = map normalizePara parasA
normB = map normalizePara parasB
nB = length normB
table = lcsTable normA normB
tableAt i j = table !! (i * (nB + 1) + j)
go 0 0 acc = reverse acc
go i j acc
| i > 0 && j > 0 && normA !! (i - 1) == normB !! (j - 1) =
go (i - 1) (j - 1) acc
| i > 0 && j > 0 && preferReplace i j =
go (i - 1) (j - 1)
( TextChange Nothing Nothing (Just (i - 1)) (Just (j - 1))
(parasA !! (i - 1)) (parasB !! (j - 1))
: acc
)
| j > 0 && (i == 0 || tableAt (i - 1) j < tableAt i (j - 1)) =
go i (j - 1)
( TextChange Nothing Nothing Nothing (Just (j - 1)) T.empty (parasB !! (j - 1))
: acc
)
| i > 0 =
go (i - 1) j
( TextChange Nothing Nothing (Just (i - 1)) Nothing (parasA !! (i - 1)) T.empty
: acc
)
| otherwise = reverse acc
-- Substitution keeps the remaining LCS; pure insert or delete would not improve it.
preferReplace i j =
let diag = tableAt (i - 1) (j - 1)
in diag >= tableAt (i - 1) j && diag >= tableAt i (j - 1)
sortChanges :: [TextChange] -> [TextChange]
sortChanges =
sortOn
(\c -> case c of
TextChange{changeParaA = pa, changeParaB = pb} -> (pa, pb)
PageCountMismatch{} -> (Nothing, Nothing))
mergeReplaceChanges :: [TextChange] -> [TextChange]
mergeReplaceChanges [] = []
mergeReplaceChanges (c : d : rest)
| isRemoval c && isAddition d =
mergedChange c d : mergeReplaceChanges rest
| isAddition c && isRemoval d =
mergedChange d c : mergeReplaceChanges rest
| otherwise = c : mergeReplaceChanges (d : rest)
mergeReplaceChanges [c] = [c]
mergedChange :: TextChange -> TextChange -> TextChange
mergedChange TextChange{changeParaA = pa, changeOld = old}
TextChange{changeParaB = pb, changeNew = new} =
TextChange Nothing Nothing pa pb old new
isRemoval :: TextChange -> Bool
isRemoval TextChange{changeOld = old, changeNew = new} =
not (T.null old) && T.null new
isRemoval _ = False
isAddition :: TextChange -> Bool
isAddition TextChange{changeOld = old, changeNew = new} =
T.null old && not (T.null new)
isAddition _ = False
-- | Comparison key for a paragraph. Whitespace is ignored: PDF extractors
-- often disagree on Latin/CJK spacing while the visible text is the same.
-- Display text in 'TextChange' stays as extracted.
normalizePara :: T.Text -> T.Text
normalizePara = T.filter (not . isSpace)
-- | Soft-normalize legacy paragraph chunks for display (strip + collapse runs).
collapseParaWS :: T.Text -> T.Text
collapseParaWS = collapseInternalWS . T.strip
where
collapseInternalWS t =
T.pack $ go False (T.unpack t)
go _ [] = []
go _ ('\r' : cs) = go False cs
go seen ('\n' : cs) = if seen then go True cs else ' ' : go True cs
go seen (c : cs)
| isSpace c = if seen then go True cs else ' ' : go True cs
| otherwise = c : go False cs
lcsTable :: Eq a => [a] -> [a] -> [Int]
lcsTable xs ys =
let m = length xs
n = length ys
row i j
| i == 0 || j == 0 = 0
| xs !! (i - 1) == ys !! (j - 1) =
1 + tableAt (i - 1) (j - 1)
| otherwise = max (tableAt (i - 1) j) (tableAt i (j - 1))
tableAt i j = table !! (i * (n + 1) + j)
table = [row i j | i <- [0 .. m], j <- [0 .. n]]
in table
-- | Shared prefix / differing middles / shared suffix for two paragraph texts.
-- Newlines are flattened to spaces so each change stays one unified-diff line.
alignChangeSpans :: T.Text -> T.Text -> (T.Text, T.Text, T.Text, T.Text)
alignChangeSpans old0 new0 =
let old = flattenOneLine old0
new = flattenOneLine new0
(pre, oldRest, newRest) = splitCommonPrefix old new
(suf, oldMid, newMid) = splitCommonSuffix oldRest newRest
in (pre, oldMid, newMid, suf)
flattenOneLine :: T.Text -> T.Text
flattenOneLine = T.map (\c -> if c == '\n' || c == '\r' then ' ' else c)
splitCommonPrefix :: T.Text -> T.Text -> (T.Text, T.Text, T.Text)
splitCommonPrefix a b =
let n = length (takeWhile id (zipWith (==) (T.unpack a) (T.unpack b)))
in (T.take n a, T.drop n a, T.drop n b)
splitCommonSuffix :: T.Text -> T.Text -> (T.Text, T.Text, T.Text)
splitCommonSuffix a b =
let ra = T.reverse a
rb = T.reverse b
n = length (takeWhile id (zipWith (==) (T.unpack ra) (T.unpack rb)))
in (T.takeEnd n a, T.dropEnd n a, T.dropEnd n b)
-- | Context characters kept on each side of the changed span; longer common
-- sides are elided with an ellipsis so editor -/+ lines stay scannable.
spanContextChars :: Int
spanContextChars = 12
trimSpanContext :: T.Text -> T.Text -> (T.Text, T.Text)
trimSpanContext pre suf =
( trimPre pre
, trimSuf suf
)
where
trimPre t
| T.length t <= spanContextChars = t
| otherwise = T.cons '\x2026' (T.takeEnd spanContextChars t)
trimSuf t
| T.length t <= spanContextChars = t
| otherwise = T.take spanContextChars t `T.append` T.singleton '\x2026'
-- | Unified-diff style rendering for CLI / @.diff@ files.
--
-- Structure uses @---@ / @+++@ / @\@\@@ / @-@ / @+@ so editor syntax
-- highlighters treat it as a diff. Consecutive paragraph changes on the
-- same page are merged into one hunk; within a hunk all deletions come
-- before all additions (classic multi-line replace), not interleaved
-- per paragraph. With 'DiffColorAnsi', only the changed span inside each
-- line is colored (TTY); redirected files should use 'DiffColorOff'.
renderUnifiedDiff :: DiffColor -> FilePath -> FilePath -> [TextChange] -> String
renderUnifiedDiff _ _ _ [] = ""
renderUnifiedDiff color fileA fileB changes =
intercalate "\n" (headerLines ++ concatMap (renderHunk color) (groupDiffHunks changes)) ++ "\n"
where
headerLines =
[ paint color meta ("--- " ++ fileA)
, paint color meta ("+++ " ++ fileB)
]
-- | Merge consecutive same-page 'TextChange's into one hunk.
groupDiffHunks :: [TextChange] -> [[TextChange]]
groupDiffHunks [] = []
groupDiffHunks (c@(PageCountMismatch{}) : rest) =
[c] : groupDiffHunks rest
groupDiffHunks (c@TextChange{} : rest) =
let (peers, rest') = span (sameHunkPage c) rest
in (c : peers) : groupDiffHunks rest'
sameHunkPage :: TextChange -> TextChange -> Bool
sameHunkPage TextChange{changePageA = pa, changePageB = pb}
TextChange{changePageA = pa', changePageB = pb'} =
pa == pa' && pb == pb'
sameHunkPage _ _ = False
renderHunk :: DiffColor -> [TextChange] -> [String]
renderHunk color [PageCountMismatch pa pb] =
[ paint color hunk "@@ page count @@"
, paintDel color (show pa ++ " pages")
, paintAdd color (show pb ++ " pages")
]
renderHunk _ (PageCountMismatch{} : _) =
-- PageCountMismatch is always alone; defensive fallback.
[]
renderHunk color cs@(TextChange{changePageA = pa, changePageB = pb} : _) =
let bodies = map (changeLines color) cs
dels = concatMap fst bodies
adds = concatMap snd bodies
in paint color hunk ("@@ " ++ hunkLocation pa pb cs ++ " @@")
: (dels ++ adds)
renderHunk _ _ = []
-- | @('-' lines, '+' lines)@ for one paragraph change.
changeLines :: DiffColor -> TextChange -> ([String], [String])
changeLines _ PageCountMismatch{} = ([], [])
changeLines color TextChange{changeOld = old, changeNew = new}
| T.null old = ([], [paintAdd color (T.unpack (flattenOneLine new))])
| T.null new = ([paintDel color (T.unpack (flattenOneLine old))], [])
| otherwise =
let (pre0, oldMid, newMid, suf0) = alignChangeSpans old new
(pre, suf) = trimSpanContext pre0 suf0
in ( [paintSpan color '-' pre oldMid suf]
, [paintSpan color '+' pre newMid suf]
)
hunkLocation :: Maybe Int -> Maybe Int -> [TextChange] -> String
hunkLocation pa pb cs =
case (pa, pb) of
(Just a, Just b) | a == b ->
"page " ++ show a ++ ", " ++ paraLabel
(Just a, Just b) ->
"page " ++ show a ++ " vs " ++ show b ++ ", " ++ paraLabel
(Just a, Nothing) ->
"page " ++ show a ++ " (only in first), " ++ paraLabel
(Nothing, Just b) ->
"page " ++ show b ++ " (only in second), " ++ paraLabel
_ -> paraLabel
where
paraLabel = formatParaRefs [ (pxa, pxb) | TextChange{changeParaA = pxa, changeParaB = pxb} <- cs ]
-- | Compact 1-based paragraph references: @para 6@, @para 6-9@, @para 6, 8-11@.
formatParaRefs :: [(Maybe Int, Maybe Int)] -> String
formatParaRefs refs =
case collect of
[] -> "para ?"
xs -> "para " ++ intercalate ", " (map formatRun (groupRuns xs))
where
collect = sort $ nub [ n | (ma, mb) <- refs, n <- maybePara ma mb ]
maybePara (Just a) (Just b)
| a == b = [a + 1]
| otherwise = [a + 1, b + 1]
maybePara (Just a) Nothing = [a + 1]
maybePara Nothing (Just b) = [b + 1]
maybePara _ _ = []
groupRuns [] = []
groupRuns (x : xs) = go [x] xs
where
go run [] = [reverse run]
go run@(r : _) (y : ys)
| y == r + 1 = go (y : run) ys
| otherwise = reverse run : go [y] ys
go _ _ = []
formatRun [a] = show a
formatRun (a : rest) = show a ++ "-" ++ show (last (a : rest))
formatRun [] = "?"
paintSpan :: DiffColor -> Char -> T.Text -> T.Text -> T.Text -> String
paintSpan DiffColorOff mark pre mid suf =
mark : T.unpack (pre `T.append` mid `T.append` suf)
paintSpan DiffColorAnsi mark pre mid suf =
let midColor = if mark == '-' then red else green
in mark
: dim (T.unpack pre)
++ midColor (T.unpack mid)
++ dim (T.unpack suf)
++ reset
paintDel :: DiffColor -> String -> String
paintDel DiffColorOff s = '-' : s
paintDel DiffColorAnsi s = '-' : red s ++ reset
paintAdd :: DiffColor -> String -> String
paintAdd DiffColorOff s = '+' : s
paintAdd DiffColorAnsi s = '+' : green s ++ reset
paint :: DiffColor -> (String -> String) -> String -> String
paint DiffColorOff _ s = s
paint DiffColorAnsi style s = style s ++ reset
meta, hunk, red, green, dim :: String -> String
meta s = "\ESC[1m" ++ s
hunk s = "\ESC[36m" ++ s
red s = "\ESC[31m" ++ s
green s = "\ESC[32m" ++ s
dim s = "\ESC[2m" ++ s
reset :: String
reset = "\ESC[0m"