% \iffalse meta-comment % % monoref.dtx % Single-pass cross-references, page totals and a table of contents in % one LuaLaTeX run -- no rerun, no latexmk, no .aux round-trip. % % Copyright (C) 2026 Srikanth Mohankumar % % This work may be distributed and/or modified under the conditions of % the LaTeX Project Public License, either version 1.3c of this license % or (at your option) any later version. The latest version of this % license is in http://www.latex-project.org/lppl.txt and version 1.3c % or later is part of all distributions of LaTeX version 2008 or later. % % This work has the LPPL maintenance status `maintained'. % The Current Maintainer of this work is Srikanth Mohankumar. % % This work consists of the files monoref.dtx and % monoref.ins and the derived files monoref.sty and % monoref.lua. % % \fi % % \iffalse %<*driver> \documentclass{ltxdoc} \providecommand\LuaLaTeX{Lua\LaTeX} \EnableCrossrefs \CodelineIndex \RecordChanges \begin{document} \DocInput{monoref.dtx} \PrintChanges \PrintIndex \end{document} % % \fi % % \CheckSum{0} % % \title{The \textsf{monoref} package\thanks{This document % corresponds to \textsf{monoref}~v1.3, dated \today.}} % \author{Srikanth Mohankumar \\ \texttt{srikanthmohankumar@gmail.com}} % \date{\today} % \maketitle % % \begin{abstract} % \textsf{monoref} resolves forward cross-references, page % references, the total page count and a multi-level table of % contents in a \emph{single} \LuaLaTeX{} run. No second pass, no % \texttt{latexmk}, and no \texttt{.aux} round-trip are required. It % holds every finished page in memory and back-patches the still % unknown values at |\end{document}|. When \textsf{hyperref} is % loaded, the references and contents entries become clickable. % \end{abstract} % % \section{Introduction} % % Normally \LaTeX{} needs at least two runs to settle cross-references: % the first writes label values, section numbers and page numbers to the % \texttt{.aux} file and the second reads them back. This package % removes that round-trip for a useful subset of the problem by never % shipping a page out immediately. Every page is \emph{held} in memory % (using the \texttt{shipout/before} hook and |\DiscardShipoutBox|); at % the end of the document all values are known, so they can be patched % into the held pages before the pages are finally shipped. % % Two ideas make it work. % \begin{description} % \item[Reserved slots.] A value that is not yet known (a forward % |\ref|, any |\pageref|, |\lastpage|) is typeset as a reserved % box. At flush the box collapses to the value's natural width and % the enclosing line is re-justified to its original width, so the % interword glue absorbs any freed space: short values (a single % digit) leave no gap. The reserved width therefore only guides the % original line break, not the final spacing. % \item[Shipout-accurate pages.] |\label| drops an invisible % |\special| marker; when a page is held it is scanned for markers % and each label is stamped with the page it really landed on. This % avoids the off-by-one you get from reading |\value{page}| at % |\label| time, because \TeX{}'s page builder is asynchronous. % \end{description} % % \section{Requirements} % % \textsf{monoref} requires \LuaLaTeX{}. It stops with an error % on any other engine. % % \section{Usage} % % \begin{verbatim} % \documentclass{article} % \usepackage{hyperref} % optional; enables clickable links % \usepackage{monoref} % load AFTER hyperref % \begin{document} % \section{Introduction}\label{sec:intro} % See Section~\ref{sec:results} on page~\pageref{sec:results}. % This document has \lastpage\ body pages. % ... % \section{Results}\label{sec:results} % ... % \end{document} % \end{verbatim} % % \DescribeMacro{\ref} % Prints the number of the referenced |\label|. A backward reference is % typeset immediately; a forward reference is emitted as a slot and % filled at the end. % % \DescribeMacro{\pageref} % Prints the page of the referenced |\label|. It is always a slot, % because a label's true page is known only once its page is assembled. % % \DescribeMacro{\lastpage} % Prints the total number of body pages (front matter excluded). % % \section{Slot width} % % Forward values are reserved with a font-relative width taken from a % template string. This width only guides the original line break; at % flush the slot collapses to the value's natural width and the line is % re-justified, so the reserved width never shows up as a gap. % \DescribeMacro{\monorefreftemplate} % \DescribeMacro{\monorefpagetemplate} % |\monorefreftemplate| (default |0.0.0|) sizes |\ref| slots; it must be at % least as wide as the widest forward (sub)section number. % |\monorefpagetemplate| (default |000|) sizes |\pageref| and |\lastpage| % slots. Redefine either with |\renewcommand|, e.g. % |\renewcommand\monorefreftemplate{0.0}| for a document with no forward % subsubsection references. % \DescribeMacro{\monorefslotwidth} % Setting the length |\monorefslotwidth| to a positive value overrides both % templates with a fixed width. % % \section{Hyperlinks} % % If \textsf{hyperref} is loaded, |\ref|, |\pageref| and the contents % entries become clickable. The links use the package's own named % destinations (derived directly from the label names), \emph{not} % \textsf{hyperref}'s \texttt{.aux}-based anchors, so linking stays % single-pass and works for forward references too. Load % \textsf{monoref} \emph{after} \textsf{hyperref}. % % \section{The table of contents} % % Without the |notoc| option a contents list covering |\section|, % |\subsection| and |\subsubsection| is collected during the run and % prepended at the end with roman folios, while the body keeps arabic % numbering; prepending the contents therefore does not renumber the % body and there is no circular dependency. Use |notoc| to switch it % off. % % \section{Limitations} % % \begin{itemize} % \item It holds the whole document in memory until |\end{document}|. % \item It redefines |\label|, |\ref|, |\pageref|, |\section|, % |\subsection| and |\subsubsection|; because it replaces % \textsf{hyperref}'s versions of these, \textsf{hyperref} features % that depend on them (|\autoref|, |\nameref|, \textsf{cleveref}) % are not available. It also conflicts with \textsf{titlesec} and % \textsf{varioref}. % \item A forward value wider than its slot template overflows. % \item Numbered-equation |\ref| works, including with \textsf{amsmath}; % but |\eqref| (the parenthesised form) and lists of figures/tables % are not provided. % \item Because it intercepts every |\shipout|, it is incompatible % with other packages that manipulate shipout. % \end{itemize} % % \section{Examples} % % The distribution ships a set of runnable examples in the % \texttt{examples/} directory; compile each once with \LuaLaTeX{}: % \begin{description} % \item[01-minimal] the smallest possible use. % \item[02-article] three heading levels, a numbered equation, forward % and backward |\ref|/|\pageref|, a page total, and a front TOC. % \item[03-hyperref-links] clickable links via \textsf{hyperref} % (loaded first, with \texttt{hidelinks}). % \item[04-notoc] the |notoc| option. % \item[05-page-of-total] a ``Page X of Y'' footer with \textsf{fancyhdr} % and |\lastpage|. % \item[06-custom-slot-width] widening the slot templates. % \end{description} % % \StopEventually{} % % \section{Implementation: the style file} % % \changes{v1.0}{2026/07/07}{Initial release} % \changes{v1.1}{2026/07/07}{Flush-left, font-relative slots; optional % hyperref links; three-level clickable TOC} % \changes{v1.2}{2026/07/07}{Slots collapse to natural width at flush and % the line is re-justified, so short values leave no gap} % \changes{v1.3}{2026/07/07}{Renamed to \textsf{monoref}; support % \textsf{amsmath} numbered-equation labels; patch slots on the % contents page; broad set of examples} % % \begin{macrocode} %<*package> \NeedsTeXFormat{LaTeX2e} \ProvidesPackage{monoref}% [2026/07/07 v1.3 Single-pass cross-references, page totals and TOC (LuaLaTeX)] \RequirePackage{iftex} \RequireLuaTeX \RequirePackage{xparse} \newif\ifmonoref@dotoc \monoref@dotoctrue \DeclareOption{notoc}{\monoref@dotocfalse} \ProcessOptions\relax \directlua{if not pcall(require, "monoref") then dofile("monoref.lua") end} \newbox\monoref@shipbox \directlua{monoref.shipreg = \number\monoref@shipbox} \attributedef\monoref@slotattr=\directlua{tex.print(monoref.slotattr)}\relax \attributedef\monoref@markattr=\directlua{tex.print(monoref.markattr)}\relax \newcommand\monorefreftemplate{0.0.0} \newcommand\monorefpagetemplate{000} \newlength\monorefslotwidth \setlength\monorefslotwidth{0pt} \newlength\monoref@tmpwd \newcommand\monoref@slot[2]{% #1 = slot id #2 = width template \begingroup \ifdim\monorefslotwidth>\z@ \monoref@tmpwd=\monorefslotwidth \else \settowidth\monoref@tmpwd{#2}\fi \monoref@slotattr=#1\relax \hbox to \monoref@tmpwd{\hfil}% \endgroup} \newcommand\monoref@mark[1]{{\monoref@markattr=#1\relax \special{}}} \newif\ifmonoref@hyper \monoref@hyperfalse \newcommand\monoref@target[1]{\ifmonoref@hyper\hypertarget{monoref:#1}{}\fi} \newcommand\monoref@link[2]{\ifmonoref@hyper\hyperlink{monoref:#1}{#2}\else#2\fi} \newcommand\lastpage{\monoref@slot{\directlua{monoref.newslot("","total")}}{\monorefpagetemplate}} \newcommand\monoref@refinner[1]{% \ifnum\directlua{monoref.known("#1")}>\z@ \directlua{monoref.ref_value("#1")}% \else \monoref@slot{\directlua{monoref.newslot("#1","ref")}}{\monorefreftemplate}% \fi} \newcommand\monoref@record[3]{% #1 level #2 number #3 title \protected@edef\monoref@nm{#2}% \protected@edef\monoref@tl{#3}% \monoref@mark{\directlua{monoref.toc_begin(#1, "\luaescapestring{\monoref@nm}","\luaescapestring{\monoref@tl}")}}% \ifmonoref@hyper \edef\monoref@ti{\directlua{monoref.toc_count()}}% \hypertarget{monoref:toc:\monoref@ti}{}% \fi} \def\monoref@activate{% \@ifpackageloaded{hyperref}{\monoref@hypertrue}{\monoref@hyperfalse}% \let\monoref@origlabel\label \let\monoref@origsection\section \let\monoref@origsubsection\subsection \let\monoref@origsubsubsection\subsubsection \RenewDocumentCommand\label{m}{% \protected@edef\monoref@cl{\@currentlabel}% \monoref@mark{\directlua{monoref.label_begin("##1","\luaescapestring{\monoref@cl}")}}% \monoref@target{lbl:##1}% \monoref@origlabel{##1}}% \RenewDocumentCommand\ref{m}{\monoref@link{lbl:##1}{\monoref@refinner{##1}}}% \RenewDocumentCommand\pageref{m}{% \monoref@link{lbl:##1}{\monoref@slot{\directlua{monoref.newslot("##1","page")}}{\monorefpagetemplate}}}% \RenewDocumentCommand\section{s o m}{% \IfBooleanTF{##1}{\monoref@origsection*{##3}}% {\IfValueTF{##2}{\monoref@origsection[##2]{##3}}{\monoref@origsection{##3}}% \monoref@record{1}{\thesection}{##3}}}% \RenewDocumentCommand\subsection{s o m}{% \IfBooleanTF{##1}{\monoref@origsubsection*{##3}}% {\IfValueTF{##2}{\monoref@origsubsection[##2]{##3}}{\monoref@origsubsection{##3}}% \monoref@record{2}{\thesubsection}{##3}}}% \RenewDocumentCommand\subsubsection{s o m}{% \IfBooleanTF{##1}{\monoref@origsubsubsection*{##3}}% {\IfValueTF{##2}{\monoref@origsubsubsection[##2]{##3}}{\monoref@origsubsubsection{##3}}% \monoref@record{3}{\thesubsubsection}{##3}}}% \@ifpackageloaded{amsmath}{\let\ltx@label\label}{}% } \AtBeginDocument{\monoref@activate} \newcommand\monoref@indent[1]{\ifcase#1\or\or\hspace{1.5em}\or\hspace{3em}\fi} \newcommand\monoref@tocline[5]{% #1 idx #2 level #3 number #4 title #5 page \par\noindent\monoref@indent{#2}% \monoref@link{toc:#1}{\makebox[2.6em][l]{#3}#4}\ \dotfill\ #5} \newcount\monoref@nt \newcount\monoref@k \newcommand\monoref@buildtoc{% \directlua{monoref.target = "toc"}% \pagenumbering{roman}% \monoref@nt=\directlua{monoref.toc_count()}\relax \ifnum\monoref@nt>\z@ \section*{\contentsname}\par\medskip \monoref@k=\z@ \loop\ifnum\monoref@k<\monoref@nt \advance\monoref@k\@ne \monoref@tocline {\the\monoref@k}% {\directlua{monoref.toc_level(\the\monoref@k)}}% {\directlua{monoref.toc_num(\the\monoref@k)}}% {\directlua{monoref.toc_title(\the\monoref@k)}}% {\directlua{monoref.toc_page(\the\monoref@k)}}% \repeat \clearpage \fi} \newif\ifmonoref@flushing \AddToHook{shipout/before}{% \ifmonoref@flushing\else \directlua{monoref.hold(\number\ShipoutBox, \the\value{page})}% \DiscardShipoutBox \fi} \newcount\monoref@i \AtEndDocument{% \clearpage \ifmonoref@dotoc \monoref@buildtoc \fi \directlua{monoref.finalize()}% \monoref@i=\directlua{monoref.qcount()}\relax \monoref@flushingtrue \loop\ifnum\monoref@i>\z@ \directlua{monoref.qpop()}% \shipout\box\monoref@shipbox \advance\monoref@i\m@ne \repeat \monoref@flushingfalse} % % \end{macrocode} % % \section{Implementation: the Lua backend} % % The backend is a plain Lua module, generated verbatim into % \texttt{monoref.lua}. Its own comment header carries the % licence (a \texttt{docstrip} \texttt{\%\%} header would be invalid % Lua), so it is shipped standalone rather than generated by the % installer. % % \begin{macrocode} %<*lua> -- monoref.lua -- Lua backend for the monoref package. -- -- Copyright (C) 2026 Srikanth Mohankumar -- This work may be distributed and/or modified under the conditions of -- the LaTeX Project Public License, version 1.3c or later. -- http://www.latex-project.org/lppl.txt monoref = monoref or {} monoref.labels = {} -- name -> {ref=, page=} monoref.slots = {} -- id -> {name=, font=, kind=} kind: ref | page | total monoref.marks = {} -- id -> {kind=, key=} kind: label | toc monoref.toc = {} -- ordered list of {level=, num=, title=, page=} monoref.held = {} -- body pages (arabic) monoref.toc_pages = {} -- front-matter pages (roman) monoref.queue = {} -- final ship order monoref.target = "body" monoref.sid, monoref.mid, monoref.total = 0, 0, 0 monoref.slotattr = luatexbase.new_attribute("monorefslot") monoref.markattr = luatexbase.new_attribute("monorefmark") local HLIST = node.id("hlist") local VLIST = node.id("vlist") local GLYPH = node.id("glyph") local GLUE = node.id("glue") -------------------------------------------------------------------------- -- Registration (all print/sprint straight back into the TeX stream) -------------------------------------------------------------------------- function monoref.known(name) tex.print(monoref.labels[name] and 1 or 0) end function monoref.newslot(name, kind) monoref.sid = monoref.sid + 1 monoref.slots[monoref.sid] = { name = name, font = font.current(), kind = kind } tex.print(monoref.sid) end local function newmark(kind, key) monoref.mid = monoref.mid + 1 monoref.marks[monoref.mid] = { kind = kind, key = key } return monoref.mid end function monoref.label_begin(name, ref) monoref.labels[name] = { ref = ref, page = 0 } tex.print(newmark("label", name)) end function monoref.ref_value(name) local L = monoref.labels[name] tex.sprint(L and L.ref or "??") end function monoref.toc_begin(level, num, title) monoref.toc[#monoref.toc + 1] = { level = level, num = num, title = title, page = 0 } tex.print(newmark("toc", #monoref.toc)) end function monoref.toc_count() tex.print(#monoref.toc) end function monoref.toc_level(i) tex.print(monoref.toc[i].level) end function monoref.toc_num(i) tex.sprint(monoref.toc[i].num) end function monoref.toc_title(i) tex.sprint(monoref.toc[i].title) end function monoref.toc_page(i) tex.print(monoref.toc[i].page) end -------------------------------------------------------------------------- -- Marker scan: stamp every label / TOC entry with the TRUE page it is on -------------------------------------------------------------------------- local function scan(head, pg) local n = head while n do local m = node.get_attribute(n, monoref.markattr) if m and monoref.marks[m] then local r = monoref.marks[m] if r.kind == "label" and monoref.labels[r.key] then monoref.labels[r.key].page = pg elseif r.kind == "toc" and monoref.toc[r.key] then monoref.toc[r.key].page = pg end end local id = n.id if id == HLIST or id == VLIST then scan(n.head, pg) end n = n.next end end function monoref.hold(reg, pg) local b = tex.getbox(reg) if not b then return end local c = node.copy(b) scan(c.head, pg) local dest = (monoref.target == "toc") and monoref.toc_pages or monoref.held dest[#dest + 1] = c end -------------------------------------------------------------------------- -- Slot filling at flush (fixed width kept -> never reflows) -------------------------------------------------------------------------- local function glyphs_of(s, fid) local fnt = font.getfont(fid) local first, last for i = 1, #s do local code = s:byte(i) local g = node.new(GLYPH) g.font, g.char, g.subtype = fid, code, 0 if fnt and fnt.characters and fnt.characters[code] then local ci = fnt.characters[code] g.width, g.height, g.depth = ci.width or 0, ci.height or 0, ci.depth or 0 end if first then last.next = g; g.prev = last; last = g else first, last = g, g end end return first, last end local function value_of(rec) if rec.kind == "total" then return tostring(monoref.total) end local L = monoref.labels[rec.name] if not L then return "??" end if rec.kind == "page" then return (L.page and L.page > 0) and tostring(L.page) or "??" end return L.ref end -- Fill one placeholder hbox to the value's NATURAL width (the enclosing -- line is re-justified afterwards so the reserved slack disappears). local function fill_slot(hbox, id) local val = value_of(monoref.slots[id]) if hbox.head then node.flush_list(hbox.head); hbox.head = nil end local gf = glyphs_of(val, monoref.slots[id].font) local packed = node.hpack(gf or node.new(GLUE)) hbox.head = packed.head hbox.width = packed.width hbox.height = packed.height hbox.depth = packed.depth hbox.glue_set = 0 hbox.glue_sign = 0 hbox.glue_order = 0 packed.head = nil node.free(packed) end -- Re-pack a line to its original width so its interword glue absorbs the -- space freed by shrinking a slot (keeps the line justified, no gap). local function rejustify(hbox) local packed = node.hpack(hbox.head, hbox.width, "exactly") hbox.head = packed.head hbox.glue_set = packed.glue_set hbox.glue_sign = packed.glue_sign hbox.glue_order = packed.glue_order hbox.height = packed.height hbox.depth = packed.depth packed.head = nil node.free(packed) end -- Patch every slot in a box; re-justify each line that contained one. local function patch_box(box) local direct = false local n = box.head while n do local id = n.id if id == HLIST or id == VLIST then local a = node.get_attribute(n, monoref.slotattr) if a and monoref.slots[a] then fill_slot(n, a) direct = true else patch_box(n) end end n = n.next end if direct and box.id == HLIST then rejustify(box) end end -------------------------------------------------------------------------- -- Finalisation and shipping -------------------------------------------------------------------------- function monoref.finalize() monoref.total = #monoref.held for i = 1, #monoref.toc_pages do patch_box(monoref.toc_pages[i]) end for i = 1, #monoref.held do patch_box(monoref.held[i]) end monoref.queue = {} for i = 1, #monoref.toc_pages do monoref.queue[#monoref.queue + 1] = monoref.toc_pages[i] end for i = 1, #monoref.held do monoref.queue[#monoref.queue + 1] = monoref.held[i] end end function monoref.qcount() tex.print(#monoref.queue) end function monoref.qpop() tex.setbox(monoref.shipreg, table.remove(monoref.queue, 1)) end return monoref % % \end{macrocode} % % \Finale \endinput