Tuesday, December 28, 2010

A fully expandable conversion to hexadecimal numbers

The TeX primitive \number will convert anything that can be converted to a number into a decimal number and what's more, it is expandable. That is \edef\foo{\number\count37} will define \foo to be whatever the value of count register 37 is. It turns out that using ε-TeX's \numexpr, it is possible to write a fully expandable macro \hexnumber that expands to the (positive) hexadecimal that is its argument. It's not quite as good as \number because it is a macro and not a primitive, but it is the best we can do without modifying TeX. To be fully explicit about what is going on here, I've opted to write this in plain TeX (with \numexpr, of course). First, we just need some boilerplate code that compares two numbers. (After I wrote this, I remembered that the LaTeX package etoolbox has a similar macro—in fact, it is implemented in exactly the same way, but that's not really surprising as it's the right way to do it—and so I renamed my macro to match etoolbox.)
\catcode@11
\def\@firstoftwo#1#2{#1}
\def\@secondoftwo#1#2{#2}
\def\ifnumcomp#1#2#3{%
\ifnum\numexpr#1\relax#2\numexpr#3\relax
\expandafter\@firstoftwo
\else
\expandafter\@secondoftwo
\fi
}
The first line is the same as LaTeX's \makeatletter. The next two are standard LaTeX definitions. Next, we need a way to divide two integers and truncate the result. TeX's primitive \divide does exactly this, but it is not expandable. \numexpr can do division; however, it rounds rather than truncates, so we need to handle that ourselves. In particular, if x<round(x/y)*y, then the division was rounded up so we need to subtract 1.
\def\truncdiv#1#2{%
\ifnumcomp{#1}<{(#1)/(#2)*(#2)}{%
\numexpr(#1)/(#2)-1%
}{%
\numexpr(#1)/(#2)%
}%
}
This brings us to the main macro.
\def\hexnumber#1{%
\ifnumcomp{#1}<0{}{\hn@i{#1}{}}%
}
If the argument is negative, expand to nothing. This is similar to the primitive \romannumeral. Otherwise call our first helper function. (Technically, there is no calling going on, just expansion.)
\def\hn@i#1#2{%
\ifnumcomp{#1}<{16}
{%
\hn@digit{#1}#2%
}{%
\expandafter\hn@ii\expandafter{%
\the\numexpr\truncdiv{#1}{16}%
}{#1}{#2}%
}%
}
This macro takes two arguments. The first is the number to convert to hex and the second is the string converted so far. When \hexnumber calls this, it passes its argument as the first argument and an empty argument for the second. If the number to convert is less than 16, convert it to hex followed by what has already been converted. Otherwise, we call the second helper macro where the first argument is the decimal representation of our value to convert divided by 16. (The \expandafters are to prevent large \numexpr expressions from piling up.) The second and third arguments are our value to convert and string converted. Our final helper macro looks complicated, but isn't terribly.
\def\hn@ii#1#2#3{%
\expandafter\hn@i\expandafter{%
\number\numexpr#1\expandafter\expandafter\expandafter
\expandafter\expandafter\expandafter\expandafter}%
\expandafter\expandafter\expandafter\expandafter
\expandafter\expandafter\expandafter{%
\hn@digit{(#2)-16*(#1)}#3}%
}
Again, we don't really need quite so many \expandafters, but they keep the second (resp. third) argument to \hn@i (resp. \hn@ii) from piling up. This is essentially just expanding to \hn@i{#1}{\hn@digit{(#2)-16*(#1)}#3} except that both arguments are fully expanded. Finally, we need to be able to turn a number 0≤x<16 into a single hex digit. One little caveat is that to act like \number, we need all of the output to have category code 12. To that end, we start a new group, change the catcodes, and then globally define \hn@digit.
\begingroup
\catcode012\catcode112\catcode212\catcode312\catcode412
\catcode512\catcode612\catcode712\catcode812\catcode912
\catcodeA12\catcodeB12\catcodeC12\catcodeD12\catcodeE12
\catcodeF12
\gdef\hn@digit#1{%
\ifcase\numexpr#1\relax 0%
\or \expandafter 1%
\or \expandafter 2%
\or \expandafter 3%
\or \expandafter 4%
\or \expandafter 5%
\or \expandafter 6%
\or \expandafter 7%
\or \expandafter 8%
\or \expandafter 9%
\or \expandafter A%
\or \expandafter B%
\or \expandafter C%
\or \expandafter D%
\or \expandafter E%
\or \expandafter F%
\fi
}
\endgroup`
And that's all there is to it!