6

Context

I'm trying to create a macro (called here \myCommand) that separates two paragraphs and adds a different vertical space depending on the context. But I suppose this is a general problem which can also occur with macros such as \section{...}.

Normally, I'd like to insert a space of value \myLength (for the purposes of this example, we will take \myLength = 30 pt) at the beginning of \myCommand and at the end of it.
If the macro falls at the top of the page, after a page break, \vspace{\myLength} is ignored and no vertical space is added, which is what I want.
BUT the problem arises when, as in the previous case, the macro falls after a page break and is preceded by one or more top floats. In this case, I'd like the macro to insert a vertical \vspace*{\dimexpr\myLength-\textfloatsep} to maintain consistency.


Example

Here is an example to illustrate:

Below, the macro behaves correctly: when it falls at the top of the page after a page break, no space is inserted at the start of the macro; and when it is not at the top of the page, \vspace{\myLength} is correctly inserted at the start of the macro.

enter image description here

However, when the macro falls at the top of the page after a page break and is preceded by a top float, the spaces are totally unbalanced.

enter image description here

Just to show that the problem is general and not specific to \myCommand, here's the same problem with \section{...}:

enter image description here

Here is a non-working minimal example :

\documentclass{article}
\usepackage{lipsum, mwe}

\newlength{\myLength}
\setlength{\myLength}{30pt}% exaggerated value to illustrate

\newcommand{\myCommand}{\par
    \vspace{\myLength}
    {\centering\LARGE * * *\par}
    \vspace{\myLength}
}

\begin{document}
\lipsum[1]

\lipsum[1]

\lipsum[1]

\lipsum[2]

\begin{figure}[t]
\centering
\includegraphics{example-image}
\end{figure}

\myCommand

\lipsum[1]

\lipsum[2]

\myCommand

\lipsum[1]

\myCommand

\lipsum[1]
\end{document}

Goal

To overcome the problem mentioned above, I would like a vertical space with a value of \vspace*{\dimexpr\myLength-\textfloatsep} (to compensate for the vertical space \textfloatsep inserted by the top float) only in the following case:
at the start of a page (after a page break) AND a top float is present.

In all other cases, using \vspace{\myLength} works perfectly.


Trials

My (so far unsuccessful) approach has been to use two conditionals to insert the right vertical spaces in the right place:

  • A first conditional to test the presence of a top float on the current page,
  • A second conditional to check that we are at the top of a new page, after a page break.

If both conditions are met, then we are indeed at the start of a page (after a page break) AND a top float is present; we then insert \vspace*{\dimexpr\myLength-\textfloatsep}. In all other cases, we insert \vspace{\myLength}.

Thanks to a previous question, I've been able to find a way of detecting the presence of a top-of-page float in a page: How to determine from the document body whether or not a page has a top float?.

The second conditional does pose a problem for me, however, and I wanted to try using \pagetotal, but it doesn't seem suitable: How to access the true value of \pagetotal?.

My macro \myCommand then looked like this:

\makeatletter
\usepackage{refcount}
%From : https://tex.stackexchange.com/questions/712713/how-to-determine-from-the-document-body-whether-or-not-a-page-has-a-top-float
\def \@combinefloats {%
    \ifx \@toplist\@empty%
    \else%
        \@cflt%
        \immediate\write\@auxout{\string\global\string\@namedef{pageWithTopFloat-\thepage}{1}}%
    \fi%
    \ifx \@botlist\@empty \else \@cflb \fi%
}

\newcounter{ifTopFloatCnt}

\long\def\ifTopFloat#1#2{%
    \global\advance\c@ifTopFloatCnt\@ne%
    \label{\the\c@ifTopFloatCnt @ifTopFloat}\nopagebreak%
    \ifcsname pageWithTopFloat-\getpagerefnumber{\the\c@ifTopFloatCnt @ifTopFloat}\endcsname%
        #1%
    \else%
        #2%
    \fi%
}

\newlength{\myLength}
\setlength{\myLength}{30pt}% exaggerated value to illustrate

\newcommand{\mycommand}{\par%
    \ifTopFloat{%
        \ifdim\pagetotal=0pt% If there is a top float AND you are at the start of a new page
            \vspace*{\dimexpr\myLength-\textfloatsep}
            {\centering\LARGE * * *\par}
        \else% If there is a top float BUT you are not at the start of a new page
            \vspace{\myLength}
            {\centering\LARGE * * *\par}
        \fi
    }{% If there is no top float
        \vspace{\myLength}
        {\centering\LARGE * * *\par}
    }    
    \vspace{\myLength}
}
\makeatother

My approach is certainly not the right one.

2
  • Your command looks unbalanced also if it is directly at the begin of a page without any float, it is then too near to the header. From the reading flow it ends a section, and so I would suppress a page break before the command so that it stays with the previous section. Commented Apr 22 at 10:20
  • @UlrikeFischer This is really an example to illustrate a problem I've encountered. The command used in the example is the simplest I could find to illustrate it, it is not the real command used in my document. However, as shown in my question, the problem is a general one and also occurs with commands such as \section.
    – B Legrand
    Commented Apr 28 at 20:12

3 Answers 3

2

Here's a LuaTeX-based solution. Anything inside the command \removefromtop will be removed if it falls at the top of the page and kept otherwise. I'm using a rule here to make the effect more obvious, but you can replace it with a \vspace (or any other vertical-mode material) for the final document.

\documentclass{article}

\usepackage{luacode}
\begin{luacode*}
    local attr = luatexbase.new_attribute("removefromtop")
    token.set_char("removefromtopattr", attr, "global")

    local cancel_output = false
    local last_cancelled = false

    luatexbase.add_to_callback("pre_output_filter", function (head)
        local outputpenalty = tex.outputpenalty
        if outputpenalty <= -10002 and outputpenalty >= -10005 then
            return true
        end

        if token.get_macro("@toplist") ~= "" then
            return true
        end

        for n in node.traverse(head) do
            if node.get_attribute(n, attr) == 1 then
                head = node.remove(head, n)
                cancel_output = true
            elseif n.id ~= node.id("penalty") and
                   n.id ~= node.id("glue")    and
                   n.id ~= node.id("kern")    and
                   n.id ~= node.id("mark")    and
                   n.id ~= node.id("whatsit") and
                   n.id ~= node.id("ins")
            then
                break
            end
        end

        if last_cancelled then
            last_cancelled = false
            for n in node.traverse_id(node.id("glue"), head) do
                if n.subtype == 10 then
                    n.width = -tex.baselineskip.width
                end
            end
        end

        if cancel_output then
            return head
        else
            return true
        end
    end, "remove_from_top")

    luatexbase.add_to_callback("buildpage_filter", function (info)
        if status.output_active then
            return
        end

        if not tex.output:match("%}%}") then
            tex.runtoks(function()
                tex.sprint[[\output\expandafter{\the\output}]]
            end)
        end

        tex.triggerbuildpage()

        if not (status.output_active and cancel_output) then
            return
        end

        cancel_output = false
        last_cancelled = true

        local level = 0
        repeat
            local t = token.get_next()
            if t.command == 1 then
                level = level + 1
            elseif t.command == 2 then
                level = level - 1
            end
        until level == 0

        local box = node.copy_list(tex.box[255])
        node.write(box)
        tex.setbox("global", 255, nil)
    end, "remove_from_top")
\end{luacode*}

\newcommand{\removefromtop}[1]{%
    \vbox attr \removefromtopattr=1 {%
        #1%
    }%
}

\usepackage{lipsum, mwe}

\newlength{\myLength}
\setlength{\myLength}{20pt}% exaggerated value to illustrate

\newcommand{\myCommand}{
    \removefromtop{
        \hrule height \myLength width \textwidth depth 0pt
    }
    \nobreak
    \vbox{
        {\centering\LARGE * * *\par}
        \hrule height \myLength width \textwidth depth 0pt
    }
}

\begin{document}
\lipsum[1]

\lipsum[1]

\lipsum[1]

\lipsum[2]

\begin{figure}[t]
\centering
\includegraphics{example-image}
\end{figure}

\myCommand

\lipsum[1]

\lipsum[2]

\myCommand

\lipsum[1]

\myCommand

\lipsum[1]

\lipsum[2]

\myCommand

\lipsum[1]

\myCommand

\lipsum[1-3]

\myCommand

\lipsum[1-9]

\myCommand

\lipsum[1]

\begin{figure}[t]
    \centering
    \includegraphics{example-image}
\end{figure}

\end{document}

output

The code is fairly complex and I haven't tested it very much, so I'd expect some bugs with more complicated documents.

How it works

First, we define a custom “attribute” that we can use to mark the contents of \removefromtop.

Next, we hook into the buildpage_filter, which is called just before TeX adds content to the “main vertical list”. The tricky bit here is that we call tex.triggerbuildpage() inside this callback, which triggers TeX to flush its “recent contributions” and to attempt a page break.

If a page break is triggered, then the control jumps to the pre_output_filter. Here, we've added another callback that first checks to see if this is a “real” page break (and not a fake page break used by LaTeX to flush floats) and that there are no top floats on the current page. If this is the case, we iterate through the page's contents and remove any initial content marked with our custom attribute. And if we marked any content, then we signal this back to our buildpage_filter and return the new page contents.

Once the pre_output_filter finishes, control resumes after the tex.triggerbuildpage(), except for now, we're inside the output routine. If the most recent pre_output_filter successfully removed some content and we're inside the first output routine, we then remove all the tokens on TeX's input stack. Since we're inside the output routine, this empties the current expansion of the \output token list. We then clear \box255 (the current page's contents), move its contents back onto the recent contributions list, and return.

Why do it this way?

We can't know whether or not the \removefromtop box is at the top of the page or not until the output routine is triggered. However, when we remove the \removefromtop boxes, we shorten the page, so if we just hooked the pre_output_filter to remove the boxes, then all the pages would be to short.

Instead, we let the output routine trigger as normal, then if we remove any of the \removefromtop boxes, we push the current page's contents back onto the recent contributions list, cancel the output routine, and let TeX proceed as normal to add more content to the bottom of the page.

2
  • Max Chernoff, thank you for your reply. I didn't fully understand how the lua code worked, but unless I'm mistaken, it doesn't detect that you're at the top of the page (in the sense of the first line of text after a page break) if a top float is present ?
    – B Legrand
    Commented Apr 28 at 20:08
  • @BLegrand Yes, that is correct. This solution works backwards from what you proposed in the question: instead of adding space before a top float, here I add the extra space unconditionally, and remove it if there aren't any top floats on the page. It should have the exact same effect, but it makes page breaking easier since we'll never run out of room by adding additional space beneath a top float. Commented Apr 28 at 22:55
1

I think the issue is with the vertical space added after the float. So in my suggestion below, I modify that using \myLength and add a \baselineskip as well.

\documentclass{article}
\usepackage{lipsum, mwe}

\newlength{\myLength}
\setlength{\myLength}{30pt}% exaggerated value to illustrate

% Below, I increase the separation between float and text and reduce
% the flexibility of the space
% You can see the default values by adding \the\textfloatsep inside
% the document environment
\newlength{\myFloatskip}
\setlength{\myFloatskip}{\the\myLength}
\addtolength{\myFloatskip}{\baselineskip}
\setlength{\textfloatsep}{\the\myFloatskip plus 0.0pt minus 0.0pt}

\newcommand{\myCommand}{\par%
    \vspace{\myLength}%
    {\centering\LARGE * * *\par}%
    \vspace{\myLength}%
}

\begin{document}

\lipsum[1]

\lipsum[1]

\lipsum[1]

\lipsum[2]

\begin{figure}[t]
\centering
\includegraphics{example-image}
\end{figure}

\myCommand

\lipsum[2]

\lipsum[3]

\myCommand

\lipsum[1]

\myCommand

\lipsum[1]
\end{document}
1
  • Thanks for your reply, but I don't want to change the value of \textfloatsep globally. I only want to modify the space between a top float and a macro (\mymacro, \section, \subsection, or other). The space between a top float and text should not be modified.
    – B Legrand
    Commented Apr 21 at 16:24
0

After several days of research, I think I've found a solution to my problem. The idea is as follows:

In the output routine, the \@cflt command assembles, where appropriate, the top floats with the body text. I modified this command so as to record in the auxiliary file the Y coordinate which represents the start of the body of the text (i.e. just after any top-of-page floats). When I run \myCommand, I do the same thing and save the Y coordinate representing the start of the macro in the auxiliary file.

At the next compilation, I compare the two Y coordinates. If they are equal, then \myCommand is just after the top floats, and I can insert \vspace*{\dimexpr\myLength-\textfloatsep} as requested in the question.

The result is as follows:

enter image description here

which is more harmonious than the original result (although minor adjustments could be made):

enter image description here


During my tests, however, I found a problem inherent in the application. In effect, the space inserted by the macro depends on the position of the macro in the page, which in turn depends on the space inserted by the macro etc. There's a circular dependency. To avoid this, I suppose you'd have to prevent a page break before or after \myCommand depending on how you want to use it. I'm open to suggestions for improvement.

For example, in my MWE, if for my 4th paragraph, I use \lipsum[2] instead of \lipsum[3], instability occurs and the compilation of the documents successively gives cases A (left) and B (right) below:

enter image description here enter image description here

If we begin with case A, the macro ends up at the top of the body text on page 2.
So at the next compilation, we'll insert a space equal to \myLength-\textfloatsep at the beginning of \myCommand. Now, the vertical space \myLength-\textfloatsep is less than the vertical space \myLength, so there's enough room for the command to be on page 1 and we're in case B.
At the next compilation, as we're no longer after a top float, \myCommand inserts a vertical space equal to \myLength. This time, there isn't enough space on page 1, and \myCommand is sent back to the beginning of page 2, so we're back in case A.


The MWE:

\documentclass{article}
\usepackage{lipsum, mwe, refcount, iftex}

\ifluatex% For compatibility with LuaTeX, which I generally use.
    \let\pdfsavepos\savepos
    \let\pdflastypos\lastypos
\fi

\makeatletter
\def \@cflt{%
    \let \@elt \@comflelt
    \setbox\@tempboxa \vbox{}%
    \@toplist
    \setbox\@outputbox \vbox{%
        \boxmaxdepth \maxdepth
        \unvbox\@tempboxa
        \vskip -\floatsep
        \topfigrule
        \vskip \textfloatsep
        \pdfsavepos% <--- Added
        \write\@auxout{\string\global\string\@namedef{@pageWithTopFloatYPos-\thepage}{\the\pdflastypos}}% <--- Added. We save the Y position of the top of "\@outputbox", i. e. the body of the text.
        \unvbox\@outputbox
    }%
    \let\@elt\relax
    \xdef\@freelist{\@freelist\@toplist}%
    \global\let\@toplist\@empty
}

\newcounter{@ifTopFloatCnt}

\long\def\ifTopFloat#1#2{% A conditional to see if there is a top float on the current page. See https://tex.stackexchange.com/questions/712713/how-to-determine-from-the-document-body-whether-or-not-a-page-has-a-top-float.
    \stepcounter{@ifTopFloatCnt}
    \label{@ifTopFloat-\the@ifTopFloatCnt}\nopagebreak%
    \ifcsname @pageWithTopFloatYPos-\getpagerefnumber{@ifTopFloat-\the@ifTopFloatCnt}\endcsname%
        #1%
    \else%
        #2%
    \fi%
}

\newlength{\myLength}
\setlength{\myLength}{30pt}% exaggerated value to illustrate

\newcounter{@myCommandCnt}
\newcounter{@myCommandCntAux}

\newcommand{\myCommand}{\par%
    \stepcounter{@myCommandCnt}%
    \pdfsavepos%
    \write\@auxout{%
        \string\stepcounter{@myCommandCntAux}%
        ^^J% New line in the auxiliary file
        \string\global\string\@namedef{@myCommandYPos-\string\the@myCommandCntAux}{\the\pdflastypos}% Save the Y coordinate.
    }%
    \ifTopFloat{%
        \ifnum \@nameuse{@myCommandYPos-\the@myCommandCnt} = 
            \csname @pageWithTopFloatYPos-\getpagerefnumber{@ifTopFloat-\the@ifTopFloatCnt}\endcsname% If there's a top float AND you're right after it
            \vspace*{\dimexpr\myLength-\textfloatsep}
            {\centering\LARGE * * * (a)\par}
        \else% If there is a top float BUT you are not right after it
            \vspace{\myLength}
            {\centering\LARGE * * * (b)\par}
        \fi
    }{% If there is no top float
        \vspace{\myLength}
        {\centering\LARGE * * * (c)\par}
    }    
    \vspace{\myLength}
}
\makeatother

\begin{document}
\lipsum[1]

\lipsum[1]

\lipsum[1]

\lipsum[3]% <--- If the current line is uncommented and the next line is commented, stability is achieved.
%\lipsum[2]% <--- If the previous line is commented and the current line is uncommented, instability occurs.

\begin{figure}[t]
\centering
\includegraphics{example-image}
\end{figure}

\myCommand

\lipsum[1]

\lipsum[2]

\begin{figure}[t]
\centering
\includegraphics{example-image-9x16}
\end{figure}

\myCommand

\lipsum[2]

\myCommand

\lipsum[1]

\myCommand

\lipsum[1]
\end{document}

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .