RTF reader: support legacy \pn/\pntext paragraph numbering (#11686).

Word-95-style numbered and bulleted lists are encoded with a
`{\pntext ...}` auto-number destination at the start of each list
paragraph plus a `{\*\pn ...}` destination describing the numbering,
rather than the modern `\listtext`/`\listtable` mechanism.  Two problems:

1. The `\pntext` marker text ("1.", "·", etc.) was captured as the
   paragraph's first text run, which sits before the paragraph's
   `\ls`/`\ilvl`, so emitBlocks (which reads list properties from the first
   run) misclassified the paragraph as an ordinary paragraph.  The first
   item of each list therefore came out as a stray paragraph.

2. The numbering style was ignored, so numbered lists defaulted to
   bullets.

Treat `\pntext` like `\listtext`: drop its visible marker text and flag the
start of a new list item.  Parse the `{\*\pn ...}` destination
(`\pnlvlbody`/`\pnlvlblt`, `\pndec`, `\pnucltr`, `\pnlcltr`,
`\pnucrm`, `\pnlcrm`, `\pnstart`, and the `\ls`/`\ilvl` keys it
carries) into the list override table so numbered lists are
emitted as ordered lists with the right number style.

`\pn` is a paragraph property that remains in effect until reset
by `\pard`, auto-numbering every paragraph in scope. Track
this (`sPnActive`) so each paragraph becomes its own list item,
rather than merging markerless continuation paragraphs as is done
for modern `\listtext` lists.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
John MacFarlane
2026-06-08 22:28:35 +00:00
parent 888a0062f1
commit 3a5f7ecee1
4 changed files with 86 additions and 5 deletions
+67 -5
View File
@@ -79,6 +79,10 @@ data RTFState = RTFState { sOptions :: ReaderOptions
, sListText :: Bool -- True after a \listtext group, , sListText :: Bool -- True after a \listtext group,
-- marking the start of a new -- marking the start of a new
-- list item -- list item
, sPnActive :: Bool -- True while legacy \pn
-- paragraph numbering is in
-- effect; every paragraph in
-- scope is its own list item
} deriving (Show) } deriving (Show)
instance Default RTFState where instance Default RTFState where
@@ -96,6 +100,7 @@ instance Default RTFState where
, sListOverrideTable = mempty , sListOverrideTable = mempty
, sEatChars = 0 , sEatChars = 0
, sListText = False , sListText = False
, sPnActive = False
} }
type FontTable = IntMap.IntMap FontFamily type FontTable = IntMap.IntMap FontFamily
@@ -456,6 +461,7 @@ processTok bs (Tok pos tok') = do
case firsttok of case firsttok of
Tok _ (ControlWord "shppict" _) -> inGroup (foldM processTok bs toks) Tok _ (ControlWord "shppict" _) -> inGroup (foldM processTok bs toks)
Tok _ (ControlWord "shpinst" _) -> inGroup (foldM processTok bs toks) Tok _ (ControlWord "shpinst" _) -> inGroup (foldM processTok bs toks)
Tok _ (ControlWord "pn" _) -> bs <$ handlePn toks
_ -> bs <$ (do oldTextContent <- sTextContent <$> getState _ -> bs <$ (do oldTextContent <- sTextContent <$> getState
processTok mempty (Tok pos (Grouped toks)) processTok mempty (Tok pos (Grouped toks))
updateState $ \st -> st{ sTextContent = oldTextContent }) updateState $ \st -> st{ sTextContent = oldTextContent })
@@ -478,13 +484,22 @@ processTok bs (Tok pos tok') = do
bs <$ inGroup (handlePict toks) bs <$ inGroup (handlePict toks)
Grouped (Tok _ (ControlWord "stylesheet" _) : toks) -> Grouped (Tok _ (ControlWord "stylesheet" _) : toks) ->
bs <$ inGroup (handleStylesheet toks) bs <$ inGroup (handleStylesheet toks)
Grouped (Tok _ (ControlWord "listtext" _) : _) -> do Grouped (Tok _ (ControlWord cw _) : _)
-- \listtext (Word 2000+) and \pntext (legacy Word 95-style
-- paragraph numbering) are both auto-generated list-marker
-- destinations. Their visible text (the bullet or number) is
-- dropped; we synthesize markers ourselves. The marker also
-- appears before the paragraph's \ls/\ilvl, so processing its
-- contents would otherwise capture the marker text as the
-- paragraph's first run, hiding the list properties from
-- emitBlocks (see #11686).
| cw == "listtext" || cw == "pntext" -> do
-- eject any previous list items...sometimes TextEdit -- eject any previous list items...sometimes TextEdit
-- doesn't put in a \par -- doesn't put in a \par
bs' <- emitBlocks bs bs' <- emitBlocks bs
-- A \listtext group marks the beginning of a new list item. -- A \listtext/\pntext group marks the beginning of a new list
-- A list paragraph lacking one is a continuation paragraph of -- item. A list paragraph lacking one is a continuation
-- the current item (see emitBlocks). -- paragraph of the current item (see emitBlocks).
updateState $ \s -> s{ sListText = True } updateState $ \s -> s{ sListText = True }
pure bs' pure bs'
Grouped (Tok _ (ControlWord "pgdsc" _) : _) -> pure bs Grouped (Tok _ (ControlWord "pgdsc" _) : _) -> pure bs
@@ -659,6 +674,9 @@ processTok bs (Tok pos tok') = do
ControlWord "pard" _ -> do ControlWord "pard" _ -> do
newbs <- emitBlocks bs newbs <- emitBlocks bs
modifyGroup (const def) modifyGroup (const def)
-- \pard resets paragraph properties, ending any legacy \pn
-- paragraph numbering that was in effect.
updateState $ \s -> s{ sPnActive = False }
getStyleFormatting 0 >>= foldM processTok newbs getStyleFormatting 0 >>= foldM processTok newbs
ControlWord "par" _ -> emitBlocks bs ControlWord "par" _ -> emitBlocks bs
_ -> pure bs _ -> pure bs
@@ -744,7 +762,10 @@ trimFinalLineBreak ils =
emitBlocks :: PandocMonad m => Blocks -> RTFParser m Blocks emitBlocks :: PandocMonad m => Blocks -> RTFParser m Blocks
emitBlocks bs = do emitBlocks bs = do
annotatedToks <- reverse . sTextContent <$> getState annotatedToks <- reverse . sTextContent <$> getState
hadListText <- sListText <$> getState -- A \listtext group marks a new item; legacy \pn paragraph numbering
-- (sPnActive) likewise makes every paragraph its own item. Note that
-- sPnActive persists across \par (until \pard) and so is not reset here.
hadListText <- (||) <$> (sListText <$> getState) <*> (sPnActive <$> getState)
updateState $ \s -> s{ sTextContent = [], sListText = False } updateState $ \s -> s{ sTextContent = [], sListText = False }
let justCode = def{ gFontFamily = Just Modern } let justCode = def{ gFontFamily = Just Modern }
let prop = case annotatedToks of let prop = case annotatedToks of
@@ -920,6 +941,47 @@ handleListLevel levelTable (lvl, toks) = do
Just numStyle -> Ordered (start,numStyle,Period) Just numStyle -> Ordered (start,numStyle,Period)
return $ IntMap.insert lvl listType levelTable return $ IntMap.insert lvl listType levelTable
-- Legacy Word-95 style paragraph numbering: {\*\pn ...}. This
-- destination configures the auto-number (or bullet) for the current
-- list paragraph. Unlike modern lists there is no \listtable, so we
-- synthesize an entry in the list override table here. The \ls and
-- \ilvl that key the table appear inside the \pn group itself, as does
-- the numbering style (\pnlvlblt for a bullet, otherwise \pndec,
-- \pnucltr, etc.). See #11686.
handlePn :: PandocMonad m => [Tok] -> RTFParser m ()
handlePn toks = do
let ls = headDef 0 [n | Tok _ (ControlWord "ls" (Just n)) <- toks]
let lvl = headDef 0 [n | Tok _ (ControlWord "ilvl" (Just n)) <- toks]
let start = headDef 1 [n | Tok _ (ControlWord "pnstart" (Just n)) <- toks]
let isBullet = not $ null [() | Tok _ (ControlWord "pnlvlblt" _) <- toks]
let pnStyle (Tok _ (ControlWord w _)) =
case w of
"pndec" -> Just Decimal
"pnucltr" -> Just UpperAlpha
"pnlcltr" -> Just LowerAlpha
"pnucrm" -> Just UpperRoman
"pnlcrm" -> Just LowerRoman
_ -> Nothing
pnStyle _ = Nothing
let mbNumberStyle
| isBullet = Nothing
| otherwise = case mapMaybe pnStyle toks of
(s:_) -> Just s
[] -> Just Decimal -- \pnlvlbody default
let listType = case mbNumberStyle of
Nothing -> Bullet
Just numStyle -> Ordered (start, numStyle, Period)
-- Don't clobber a type already registered via a real \listtable.
-- \pn is a paragraph property that stays in effect (auto-numbering or
-- bulleting every paragraph) until reset by \pard, so flag it so that
-- each following paragraph becomes its own list item.
updateState $ \s ->
s{ sListOverrideTable =
IntMap.insertWith (\new old -> IntMap.union old new) ls
(IntMap.singleton lvl listType)
(sListOverrideTable s)
, sPnActive = True }
handleListOverrideTable :: PandocMonad m => [Tok] -> RTFParser m () handleListOverrideTable :: PandocMonad m => [Tok] -> RTFParser m ()
handleListOverrideTable toks = mapM_ handleListOverride toks handleListOverrideTable toks = mapM_ handleListOverride toks
+1
View File
@@ -36,6 +36,7 @@ tests = map rtfTest [ "footnote"
, "list_simple" , "list_simple"
, "list_complex" , "list_complex"
, "list_multiparagraph" , "list_multiparagraph"
, "list_legacy"
, "bookmark" , "bookmark"
, "table_simple" , "table_simple"
, "table_error_codes" , "table_error_codes"
+14
View File
@@ -0,0 +1,14 @@
Pandoc
Meta { unMeta = fromList [] }
[ OrderedList
( 1 , Decimal , Period )
[ [ Para [ Str "A" ] ]
, [ Para [ Str "Numbered" ] ]
, [ Para [ Str "List" ] ]
]
, BulletList
[ [ Para [ Str "A" ] ]
, [ Para [ Str "Bulleted" ] ]
, [ Para [ Str "List" ] ]
]
]
+4
View File
@@ -0,0 +1,4 @@
{\rtf1\ansi\deff0{\fonttbl{\f0 Helvetica;}{\f2 Symbol;}{\f3 Symbol;}}
\plain\plain\f0\fs20\pard\ssparaaux0\s0{\pntext\pard\plain\f2\fs22 1.\tab}{\*\pn\pnlvlbody\pnstart1\pndec\ls3\ilvl0\pnhang{\pntxta \'2e}}\ls3\ilvl0\fi-360\li360\ql\plain\f0\fs20 A\par Numbered\par List\par
\pard\ssparaaux0\s0{\pntext\pard\plain\f3\fs22 \'b7\tab}{\*\pn\pnlvlblt\ls5\ilvl0\pnhang\pnf3{\pntxtb \'b7}}\ls5\ilvl0\fi-360\li360\ql\plain\f0\fs20 A\par Bulleted\par List\par
}