diff --git a/Makefile b/Makefile index 5a5f466..ee01fee 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ marginfix.pdf:marginfix.dtx .PHONY: test clean -test:margintest.pdf tufte.pdf ragged.pdf defer.pdf phantom.pdf float.pdf stretch.pdf issue-15.pdf +test:margintest.pdf tufte.pdf ragged.pdf defer.pdf phantom.pdf float.pdf stretch.pdf issue-15.pdf anchorpage.pdf margintest.pdf:marginfix.sty test/margintest.tex pdflatex test/margintest.tex @@ -38,5 +38,10 @@ stretch.pdf:marginfix.sty test/stretch.tex issue-15.pdf:marginfix.sty test/issue-15.tex pdflatex test/issue-15.tex +# Two passes: the first records anchor pages, the second fixes placement. +anchorpage.pdf:marginfix.sty test/anchorpage.tex + pdflatex test/anchorpage.tex + pdflatex test/anchorpage.tex + clean: rm *.log *.aux *.pdf tufte.out marginfix.sty diff --git a/marginfix.dtx b/marginfix.dtx index 914f95b..f5cbf37 100644 --- a/marginfix.dtx +++ b/marginfix.dtx @@ -12,7 +12,7 @@ % %\NeedsTeXFormat{LaTeX2e} %\ProvidesPackage{marginfix}% - [2020/05/06 v1.2 Fix Margin Paragraphs] + [2026/06/23 v1.3 Fix Margin Paragraphs] %<*driver> \documentclass{ltxdoc} \CheckSum{1159} @@ -73,6 +73,10 @@ % \changes{v1.2}{2020/05/06} % {(SDH) Fix long-standing bug where margin notes called out in % the last few points of a page were being entirely dropped.} +% \changes{v1.3}{2026/06/23} +% {(Daniel Bosk) Keep each margin note on the page of its callout: +% record callout pages in the .aux and, on the next run, hold back +% notes that \LaTeX{} would otherwise set on the previous page.} % % \GetFileInfo{marginfix.dtx} % \title{\Lopt{marginfix} package documentation} @@ -96,6 +100,16 @@ % concept behind this algorithm goes to Prof. Andy Ruina, who employed me % to work on some of his textbook macros in 2007--9. % +% As of v1.3 the package also keeps each note on the page of its callout. +% A \cs{marginpar} is a float, so \LaTeX{} can attach it to the page it is +% assembling even when the page then breaks \emph{before} the line that +% called it---leaving a note whose mark is at the top of a page set in the +% margin of the previous page. marginfix now records each callout's page in +% the \texttt{.aux} file and, on the next run, holds such a note back until +% its own page is shipped (requesting a rerun through the usual ``Label(s) +% may have changed'' mechanism). This is automatic and needs no markup; +% like cross-references it simply takes an extra pass to settle. +% % \section{Options} % There are currently no options that do anything yet. % @@ -501,16 +515,49 @@ \expandafter\ifx\@marbox\Mfx@inject@insert \mfx@injected\global\let\mfx@injected\@empty \else - \MFX@cons\mfx@marginlist{% - \noexpand\mfx@build@note\@currbox\@marbox{\mfx@ypos}% - \noexpand\mfx@build@skip{\the\marginparpush}% - }% + \global\advance\mfx@placeid\@ne + \mfx@maybe@defer \fi %\MFX@debug{addmarginpar (exit): marginlist=\MFX@mac\mfx@marginlist}% } % \end{macrocode} % \end{macros} % +% \begin{macros}{\mfx@maybe@defer,\mfx@place@now,\mfx@defer@note} +% This is where ``keep notes on their anchor's page'' hooks in. Every +% real marginpar is given a sequential id (\cs{mfx@placeid}) matching the +% id assigned at callout time (\cs{mfx@callid}); see \cs{mfx@anchorpage}. +% If a previous run recorded the page of this note's anchor (its callout) +% and that page lies \emph{after} the page we are currently shipping, then +% \cs{marginpar} drifted backwards across a page break: instead of setting +% the note here we hold it in \cs{mfx@deferred} until its anchor page comes +% around (see \cs{mfx@release@deferred}). With no recorded anchor (first +% run, or a brand-new note) we behave exactly as before. +% \begin{macrocode} +\def\mfx@maybe@defer{% + \@ifundefined{mfx@ap@\the\mfx@placeid}% + {\mfx@place@now}% + {\ifnum\@nameuse{mfx@ap@\the\mfx@placeid}>\c@page + \mfx@defer@note + \else + \mfx@place@now + \fi}% +} +\def\mfx@place@now{% + \MFX@cons\mfx@marginlist{% + \noexpand\mfx@build@note\@currbox\@marbox{\mfx@ypos}% + \noexpand\mfx@build@skip{\the\marginparpush}% + }% +} +\def\mfx@defer@note{% + \MFX@cons\mfx@deferred{% + \noexpand\mfx@deferred@note\@currbox\@marbox + {\@nameuse{mfx@ap@\the\mfx@placeid}}% + }% +} +% \end{macrocode} +% \end{macros} +% % \begin{macros}{\MFX@cons,\MFX@snoc} % In passing we'll define the cons macro, which fully-expands % its second argument, but makes sure to only expand the first @@ -557,7 +604,9 @@ \expandafter\@elt\expandafter\Mfx@inject@insert \@freelist}% \expandafter\def\expandafter\mfx@injected\expandafter{\mfx@injected#1}% + \mfx@in@injecttrue \marginpar{}% + \mfx@in@injectfalse } % \end{macrocode} % \end{macros} @@ -599,11 +648,161 @@ \newdimen\Mfx@strutheight \edef\marginpar{% \unexpanded{\setbox\@tempboxa\hbox{\strut}\Mfx@strutheight\ht\@tempboxa}% + \unexpanded{\mfx@marginpar@callout}% \expandafter\unexpanded\expandafter{\marginpar}% } % \end{macrocode} % \end{macros} % +% \section{Keeping notes on their anchor's page} +% A \cs{marginpar} is a float, and \LaTeX{} can attach it to the page it is +% busy assembling even when the page eventually breaks \emph{before} the line +% that called it---so a note whose callout sits at the top of a page can be +% set in the margin of the \emph{previous} page. marginfix arranges notes +% within whatever page \LaTeX{} hands them to it, and can push overflow +% forwards, but on its own it has no notion of which page a note ``belongs'' +% to, so it cannot repair this backwards drift. +% +% We fix it across two passes. Each real marginpar is numbered in document +% order, and at callout time we record the page of that callout in the +% \texttt{.aux} file with a deferred \cs{write} (the same trick \cs{label} +% uses, so the page is captured at shipout). On the next run that recorded +% \emph{anchor page} lets \cs{@addmarginpar} recognise a note that would be +% set too early and hold it back until its anchor page is shipped. Because +% marginfix attaches margin material only after the body column is built, +% margin notes never move the body's page breaks, so anchor pages are stable +% and the process converges---usually in a single extra pass, requested +% through the kernel's own rerun machinery. +% +% \begin{macros}{\mfx@callid,\mfx@placeid,\ifmfx@in@inject,\mfx@deferred} +% Two counters number the marginpars: \cs{mfx@callid} at callout time and +% \cs{mfx@placeid} in the output routine. They stay in lock-step because +% every real marginpar passes through each exactly once, in the same order. +% \cs{ifmfx@in@inject} is true only while \cs{MFX@inject} fakes a +% \cs{marginpar} for its own bookkeeping, so those are skipped. +% \cs{mfx@deferred} holds notes waiting for their anchor page. +% \begin{macrocode} +\newcount\mfx@callid +\newcount\mfx@placeid +\newif\ifmfx@in@inject +\let\mfx@deferred\@empty +% \end{macrocode} +% \end{macros} +% +% \begin{macros}{\mfx@cpage} +% \cs{mfx@cpage} expands to the raw (arabic) page number. When it is +% \cs{let} to \cs{relax} inside \cs{protected@write} it survives the write's +% \cs{edef} unexpanded and then expands at shipout, giving us the page the +% callout actually landed on (regardless of any fancy \cs{thepage} format). +% \begin{macrocode} +\def\mfx@cpage{\number\c@page} +% \end{macrocode} +% \end{macros} +% +% \begin{macros}{\mfx@marginpar@callout} +% Prepended to \cs{marginpar}, this numbers each real callout and records its +% anchor page for the next run. +% \begin{macrocode} +\def\mfx@marginpar@callout{% + \ifmfx@in@inject\else + \global\advance\mfx@callid\@ne + \if@filesw + \protected@write\@auxout{\let\mfx@cpage\relax}{% + \string\mfx@anchorpage{\the\mfx@callid}{\mfx@cpage}}% + \fi + \fi +} +% \end{macrocode} +% \end{macros} +% +% \begin{macros}{\mfx@anchorpage} +% This is what the \texttt{.aux} file calls back. Normally (while the file +% is read at \cs{begin}\texttt{\{document\}}) it just stores the anchor page +% keyed by id. During the kernel's end-of-run consistency check the aux is +% re-read with \cs{@newl@bel} \cs{let} to \cs{@testdef}; we detect that and, +% if an anchor page is new or has changed, set \cs{@tempswa} so the kernel +% issues its standard ``Label(s) may have changed. Rerun'' warning---which +% \texttt{latexmk} and friends already act on. +% \begin{macrocode} +\def\mfx@anchorpage#1#2{% + \ifx\@newl@bel\@testdef + \@ifundefined{mfx@ap@#1}% + {\@tempswatrue}% + {\def\reserved@a{#2}% + \expandafter\ifx\csname mfx@ap@#1\endcsname\reserved@a\else + \@tempswatrue + \fi}% + \else + \global\@namedef{mfx@ap@#1}{#2}% + \fi +} +% \end{macrocode} +% \end{macros} +% +% \begin{macros}{\mfx@release@deferred,\mfx@release@one,\mfx@deferred@note} +% Run at the start of every \cs{MFX@combinefloats@before}. We walk the +% deferred list: a note whose anchor page is still ahead of the page being +% shipped stays deferred; one whose page has arrived is released by prepending +% it to \cs{mfx@marginlist}, ahead of that page's own notes and at the top of +% the text block (its callout drifted from near the page top), so marginfix's +% normal building and stacking take over from there. Released notes are +% collected in order and prepended as a block so several notes landing on the +% same page keep their document order. +% \begin{macrocode} +\def\mfx@release@deferred{% + \ifx\mfx@deferred\@empty\else + \let\mfx@deferred@kept\@empty + \let\mfx@released\@empty + \let\mfx@deferred@note\mfx@release@one + \mfx@deferred + \global\let\mfx@deferred\mfx@deferred@kept + \ifx\mfx@released\@empty\else + \xdef\mfx@marginlist{% + \unexpanded\expandafter{\mfx@released}% + \unexpanded\expandafter{\mfx@marginlist}}% + \fi + \fi +} +\def\mfx@release@one#1#2#3{% + \ifnum#3>\c@page + \MFX@cons\mfx@deferred@kept{\noexpand\mfx@deferred@note#1#2{#3}}% + \else + \MFX@cons\mfx@released{% + \noexpand\mfx@build@note#1#2{0pt}% + \noexpand\mfx@build@skip{\the\marginparpush}}% + \fi +} +% \end{macrocode} +% \end{macros} +% +% \begin{macros}{\mfx@flush@deferred,\mfx@flush@one} +% A safety net: if a recorded anchor page is stale (e.g.\ the document shrank +% since the run that wrote it) a deferred note's page might never come around. +% \cs{dumpmargins} calls this at end of document to release everything that is +% still waiting, so a note is never silently dropped---worst case it lands a +% little late, exactly as an over-long note would today. +% \begin{macrocode} +\def\mfx@flush@deferred{% + \ifx\mfx@deferred\@empty\else + \let\mfx@released\@empty + \let\mfx@deferred@note\mfx@flush@one + \mfx@deferred + \global\let\mfx@deferred\@empty + \ifx\mfx@released\@empty\else + \xdef\mfx@marginlist{% + \unexpanded\expandafter{\mfx@released}% + \unexpanded\expandafter{\mfx@marginlist}}% + \fi + \fi +} +\def\mfx@flush@one#1#2#3{% + \MFX@cons\mfx@released{% + \noexpand\mfx@build@note#1#2{0pt}% + \noexpand\mfx@build@skip{\the\marginparpush}}% +} +% \end{macrocode} +% \end{macros} +% % \section{Shipout-time patches} % \begin{macros}{\@combinefloats} % We need to patch in somewhere before \cs{@combinefloats} at the latest, @@ -627,6 +826,7 @@ % \begin{macrocode} \def\MFX@combinefloats@before{% \advance\Mfx@marginheight\marginheightadjustment + \mfx@release@deferred \MFX@buildmargin \MFX@attachmargin \global\Mfx@marginheight\z@ @@ -1428,6 +1628,7 @@ % \begin{macrocode} \def\dumpmargins{% %\MFX@debug{dumpmargins}% + \mfx@flush@deferred \loop \unless\ifx\mfx@marginlist\@empty %\MFX@debug{dumpmargins: marginlist=\MFX@mac\mfx@marginlist}% diff --git a/test/anchorpage.tex b/test/anchorpage.tex new file mode 100644 index 0000000..69ff402 --- /dev/null +++ b/test/anchorpage.tex @@ -0,0 +1,42 @@ +% Test for "keep notes on their anchor's page". +% +% A marginpar whose callout lands near the top of a page can be set in the +% margin of the PREVIOUS page; the anchor-page machinery moves it back beside +% its callout. Whether the bug actually triggers is sensitive to exact +% pagination, so this file is primarily an integrity/convergence check: +% +% * anchor pages are recorded in the .aux on the first run; +% * the kernel's "Label(s) may have changed. Rerun" warning fires once and +% then clears (i.e. the process converges) -- check the two .log files; +% * notes (including the cluster of three on one line) are neither lost nor +% overprinted. +% +% Needs two runs (first records, second corrects). Build with latexmk, or +% run pdflatex twice. A real-world reproduction of the drift it fixes is the +% margin citations in the DD2520 "appliedcrypto" course notes. +\documentclass{article} +\usepackage[a5paper,margin=18mm,marginparwidth=22mm]{geometry} +\usepackage{marginfix} +\usepackage{lipsum} +\setlength{\marginparpush}{4pt} + +\newcounter{note} +\newcommand{\note}{\stepcounter{note}% + \textsuperscript{\thenote}\marginpar{\footnotesize\thenote: note anchored + here -- it must sit on the SAME page as this number.}} + +\begin{document} +% Fill most of page 1 so the next paragraph starts at the top of page 2. +\lipsum[1-4] + +% These callouts should land near the top of page 2. Three in one line +% exercises stacking (they must not overprint). +A clustered citation point\note{} with a second\note{} and a third\note{} +right after it, all anchored at the top of the page. +\lipsum[5-6] + +% A lone callout near a later page break. +\lipsum[7-11] +Another callout\note{} near a page boundary. +\lipsum[12-13] +\end{document}