xstring - How to define a macro that removes final punctuation?

Is it possible to create a command to remove final punctuation from a sentence (here to simplify, just remove the final period)?

Thanks to the xstring package, I can remove the final punctuation for a simple sentence, but it doesn't work if the sentence is included in \textit, \textbf etc.

Is it possible to tell xstring to ignore such commands and only look at their argument?

After reading the documentation, I didn't see a command to do this.

\documentclass{article}
\usepackage{xstring}

\newcommand{\removePunct}[1]{{\exploregroups%
\StrRemoveBraces{#1}[\resultA]
\IfEndWith{\resultA}{.}{%
\StrGobbleRight{#1}{1}[\resultB]%
\resultB%
}{#1}
}}

\begin{document}
\removePunct{This is a test.}

\removePunct{{This is a test.}}

\removePunct{\textit{This is a test.}}
\end{document}

• Why don't you use \textit{\removePunct{This is a test.}}? Sep 29, 2021 at 14:20
• @Niranjan Unfortunately, the context of use of this command does not allow it :/ Sep 29, 2021 at 14:39

For the sake of variety, let's also consider a LuaLaTeX-based solution. It defines a LaTeX utility macro called \RemovePunct, which in turn calls a Lua function called removepunct to do most of the work. removepunct employs Lua's versatile string.gsub function, which makes use of Lua's powerful pattern matching and capture capabilities.

Note that this solution removes punctuation characters only if they occur at the end of the argument of \RemovePunct (other than any closing curly braces and whitespace). It'll remove both single and multiple instances of such punctuation characters. Any whitespace in the argument of \RemovePunct after the final punctuation mark is also removed.

% !TEX TS-program = lualatex
\documentclass{article}
\usepackage{luacode} % for 'luacode*' environment and '\luastringN' macro
\begin{luacode*}

   function removepunct ( s )
tex.sprint ( ( s:gsub ( "%p*([}%s]-)$" , function ( v ) return ( v:gsub ( "%s" , "" ) ) end ) ) ) end  \end{luacode*} %% LaTeX-side code: \newcommand{\RemovePunct}[1]{\directlua{removepunct(\luastringN{#1})}} \begin{document} \RemovePunct{This is a test. This is also a test. } \RemovePunct{{This is a test. Is this also a test? } } \RemovePunct{\textit{This is a test. This is indeed a test! } } \RemovePunct{\textit{\textbf{This is a test. This is also a test!!! } }} \end{document}  Addendum to address the OP's follow-up question, about the meaning of the Lua code chunk in the answer shown above. • The "sprint" in tex.sprint is short for "string print", not "run very fast for a short period of time". • The "gsub" in string.gsub is short for "general subsitution". The code chunk actually contains 2 string.gsub instructions. • The "inner" one -- v:gsub ( "%s" , "" ) -- serves to purge whitespace characters, denoted by the "magic" character %s, from the search string. • The outer one starts with s:gsub ( "%p*([}%s]-)$". This means that it operates on s (which is a string passed to the Lua function by the \RemovePunct macro) and employs the search string

%p*([}%s]-)$ There are three main components to this search string: %p*, ([}%s]-), and $. Let's work from back to front: In a Lua search pattern, $ denotes "end of string", i.e., it serves as an "anchor": only the very last parts of a string will be considered in the search. [}%s] sets up a Lua "character class", consisting of two characters: } ("closing curly brace"), and %s -- the "magic character that denotes any whitespace character. [}%s]- means "0 or more instances of characters in the [}%s] character class. The round parentheses around [}%s]- denote a Lua capture, i.e., a pattern match will be treated as a capture that can be addressed as a whole during the string replacement phase. Finally, %p is the Lua "magic character" for "any punctuation character". A Lua punctuation character is not just one of the sentence-ending variety characters, .?!, but also ,, ;, and even +-*/, plus a few more. %p* denotes "0 or more punctuation-type characters (greedy)". Putting everything together in the search string %p*([}%s]-)$, we see that it means "0 or more punctuation type characters (greedy), followed by 0 or more instances of %s (whitespace) or } (non-greedy), followed by the end-of-line signifier $. If a pattern match occurs, the [}%s]- portion is "captured". Observe that the search pattern does not care about what happens earlier in the string s; that's by design. • This brings us to the string replacement action of the "outer" string.gsub directive. Frequently, this will be a static string. For the solution at hand, however, it's "dynamic", in the sense that it's given by a Lua function: function ( v ) return ( v:gsub ( "%s" , "" ) ) end  Recall that the search part looked for strings that match the pattern %p*([}%s]-)$ and that if a match occurs, the [}%s]- is captured as an entity in and of itself. Since the function takes only one argument, v, this argument must be the contents of the capture. (If there were 3 captures in the search string, they'd be addressed as, say, function (u,v,w).) Aside: since %p* is not placed in a capture, it is discarded at this point; that's what we wanted all along, wasn't it? As discussed earlier, the function operates on v by discarding whitespace.

Let's see how this all comes together by examining what happens when LaTeX encounters

\RemovePunct{\textit{This is a test. This is indeed a test! } }


The argument of this command is

|\textit{This is a test. This is indeed a test! } |


where | denotes the start and end of the argument. Observe that the last few characters of the argument are test! }  and that there's whitespace both before and after }. The \luastringN macro places this argument without expansion into a string that's passed to our favorite Lua function for further processing; our Lua function refers to this input argument as s. A pattern match occurs, and hence s is transformed to

|\textit{This is a test. This is indeed a test}|


Observe that the final punctuation character ! and the trailing whitespace characters are gone. The Lua function passes this modified string to the tex.sprint function for final disposition. (If you are eagle-eyed, you may notice that the argument of tex.sprint is encased in two pairs, not one pair, of round parentheses. This is deliberate. The reason is that string.gsub actually returns 2 outputs: (a) the replacement string(s) and (b) the number of times a replacement was performed. Encasing the output of gsub in an extra pair of parentheses is a quick and easy way of discarding the second argument.)

I hope this helps.

Second addendum, to address the OP's question about how to suppress \dots, \ldots, and \textellipsis if they occur at the end of the argument of \RemovePunct (other than, possibly, closing curly braces and whitespace).

This added objective could probably be handled in many different ways. An easy (and, hopefully, easy-to-understand) way is to add the instructions

      s = s:gsub ( "\\textellipsis([}%s]-)$" , "%1" ) s = s:gsub ( "\\l?dots([}%s]-)$" , "%1"


right after the start of the removepunct Lua function. This approach should not entail significant overhead -- unless you start to purge lots and lots of TeX macros at the end of the argument of \RemovePunct.

The revised/augmented removepunct function would thus look like this:

   function removepunct ( s )
s = s:gsub ( "\\textellipsis([}%s]-)$" , "%1" ) s = s:gsub ( "\\l?dots([}%s]-)$" , "%1" )
s = s:gsub ( "%p*([}%s]-)$" , function ( v ) return ( v:gsub ( "%s" , "" ) ) end ) tex.sprint ( s ) end  • Thank you for your answer. Could you explain me the following syntax? tex.sprint ( ( s:gsub ( "%p*([}%s]-)$" , function ( v ) return ( v:gsub ( "%s" , "" ) ) end ) ) ) Sep 30, 2021 at 18:44
• @Bastien - I just finished posting the addendum. I hope it's helpful to you.
– Mico
Sep 30, 2021 at 19:46
• After a few days of testing, it turns out that your solution seems the most versatile, so I accepted your solution. Nevertheless, I will ask you the same question as for Egreg's answer : how to make Lua ignore \ldots and \textellipsis ? Oct 4, 2021 at 10:40
• @Bastien - By "ignore \ldots and \textellipsis", do you mean (a) "don't do anything to \ldots and \textellipsis", or do you actually mean (b) purge \ldots and \textellipsis from the input stream? Please advise.
– Mico
Oct 4, 2021 at 14:58
– Mico
Oct 4, 2021 at 20:29

Easy with expl3:

\documentclass{article}

%\usepackage{xparse} % uncomment for LaTeX prior to 2020-10-01

\ExplSyntaxOn
\NewDocumentCommand{\removePunct}{m}
{
\tl_set:Nn \l_tmpa_tl { #1 }
\regex_replace_all:nnN { (\,|\;|\:|\.|\!|\?) } { } \l_tmpa_tl
\tl_use:N \l_tmpa_tl
}
\ExplSyntaxOff

\begin{document}

\removePunct{This has, as you see, no punctuation; really. Really?!}

\removePunct{\textit{This has, \textbf{as you see}, \textsc{no} punctuation; really. Really?!}}

\end{document}


If you just want to remove a trailing period:

\documentclass{article}

%\usepackage{xparse} % uncomment for LaTeX prior to 2020-10-01

\ExplSyntaxOn
\NewDocumentCommand{\removePunct}{m}
{
\tl_set:Nn \l_tmpa_tl { #1 }
\regex_replace_all:nnN { (\,|\;|\:|\.|\!|\?) } { } \l_tmpa_tl
\tl_use:N \l_tmpa_tl
}
\NewDocumentCommand{\removeFinalPeriod}{m}
{
\tl_set:Nn \l_tmpa_tl { #1 }
\regex_replace_once:nnN { \.([^[:alpha:]]*) \Z } { \1 } \l_tmpa_tl
\tl_use:N \l_tmpa_tl
}

\ExplSyntaxOff

\begin{document}

\removePunct{This has, as you see, no punctuation; really. Really?!}

\removePunct{\textit{This has, \textbf{as you see}, \textsc{no} punctuation; really. Really?!}}

\removeFinalPeriod{This has no period.}

\removeFinalPeriod{This has no period}

\removeFinalPeriod{\textit{This has no period.}}

\end{document}


• Thank you for your answer and for introducing me to the syntax of expl3 which I don't know. Sep 29, 2021 at 14:38
• Just a small point of clarification: how do you remove the ellipsis? \regex_replace_once:nnN{\,|\;|\:|\.|\!|\?|\ldots|\textellipsis ([^[:alpha:]]*) \Z}{\1} \l_tmpa_tl does not seem to work. Sep 29, 2021 at 15:28
• @Bastien \c{ldots} and \c{textellipsis} Sep 29, 2021 at 15:29
• Thank you very much ;) Sep 29, 2021 at 15:31
• @Mico Transfer (\,|\;|\:|\.|\!|\?) in place of \. and change \1 into \2 in \removeFinalPeriod Sep 30, 2021 at 20:58

With a token cycle.

\documentclass{article}
\usepackage{tokcycle}
\newcommand\removePunct[1]{%
\def\bufchar{}%
\stripgroupingtrue
\tokcycle
{\purgebuffer\gdef\bufchar{##1}}
{\purgebuffer\groupedcytoks{\processtoks{##1}%
\expandafter\testbuffer\bufchar\empty}}
{#1}%
\expandafter\testbuffer\bufchar\empty
\the\cytoks
}
\newcommand\testbuffer[1]{%
\ifx.#1\else
\ifx,#1\else
\ifx!#1\else
\ifx?#1\else
\purgebuffer
\fi\fi\fi\fi
}
\begin{document}
\removePunct{This is a test.}

\removePunct{{This is a test.}}

\removePunct{\textit{This is a test?}}

\removePunct{\rule{1ex}{1ex}, \textbf{\textit{This is a test!}}}

\removePunct{\textit{This is a test. \today}}
\end{document}