From 98bbe9f6255283aebab5ce6151393e1ff9014577 Mon Sep 17 00:00:00 2001 From: Norbert Preining Date: Sat, 28 May 2022 03:01:14 +0000 Subject: CTAN sync 202205280301 --- .../lua-widow-control/lua-widow-control.lua | 815 ++++++++++++++------- 1 file changed, 550 insertions(+), 265 deletions(-) (limited to 'macros/luatex/generic/lua-widow-control/lua-widow-control.lua') diff --git a/macros/luatex/generic/lua-widow-control/lua-widow-control.lua b/macros/luatex/generic/lua-widow-control/lua-widow-control.lua index 2af5575799..4bd109e483 100644 --- a/macros/luatex/generic/lua-widow-control/lua-widow-control.lua +++ b/macros/luatex/generic/lua-widow-control/lua-widow-control.lua @@ -7,22 +7,24 @@ --- Tell the linter about node attributes --- @class node ---- @field prev node ---- @field next node +--- @field depth integer +--- @field height integer --- @field id integer ---- @field subtype integer +--- @field list node +--- @field next node --- @field penalty integer ---- @field height integer ---- @field depth integer +--- @field prev node +--- @field subtype integer --- Set some default variables +-- Initial setup lwc = lwc or {} lwc.name = "lua-widow-control" -lwc.nobreak_behaviour = "keep" -- Locals for `debug_print` -local write_nl = texio.write_nl +local debug_lib = debug local string_rep = string.rep +local write_nl = texio.write_nl + local write_log if status.luatex_engine == "luametatex" then write_log = "logfile" @@ -31,10 +33,11 @@ else end --- Prints debugging messages to the log, only if `debug` is set to `true`. +--- --- @param title string The "title" to use --- @param text string? The "content" to print --- @return nil -local function debug_print(title, text) +local function debug(title, text) if not lwc.debug then return end -- The number of spaces we need @@ -47,6 +50,7 @@ local function debug_print(title, text) end end + --[[ \lwc/ is intended to be format-agonistic. It only runs on Lua\TeX{}, but there are still some slight differences between formats. Here, we @@ -70,58 +74,73 @@ end --[[ Save some local copies of the node library to reduce table lookups. - This is probably a useless micro-optimization, but it can't hurt. + This is probably a useless micro-optimization, but it is done in all of the + ConTeXt and expl3 Lua code, so I should probably do it here too. ]] -- Node ID's +-- (We need to hardcode the subid's sadly) +local id_from_name = node.id local baselineskip_subid = 2 -local glue_id = node.id("glue") -local glyph_id = node.id("glyph") -local hlist_id = node.id("hlist") +local glue_id = id_from_name("glue") +local glyph_id = id_from_name("glyph") +local hlist_id = id_from_name("hlist") +local insert_id = id_from_name("insert") or id_from_name("ins") local line_subid = 1 local linebreakpenalty_subid = 1 -local par_id = node.id("par") or node.id("local_par") -local penalty_id = node.id("penalty") +local par_id = id_from_name("par") or id_from_name("local_par") +local penalty_id = id_from_name("penalty") -- Local versions of globals local abs = math.abs -local copy = node.copy_list or node.copylist +local copy = node.copy +local copy_list = node.copy_list or node.copylist local find_attribute = node.find_attribute or node.findattribute -local flush_list = node.flush_list or node.flushlist local free = node.free -local getattribute = node.get_attribute or node.getattribute +local free_list = node.flush_list or node.flushlist +local get_attribute = node.get_attribute or node.getattribute local insert_token = token.put_next or token.putnext local last = node.slide +local linebreak = tex.linebreak local new_node = node.new -local node_id = node.is_node or node.isnode local set_attribute = node.set_attribute or node.setattribute local string_char = string.char +local tex_box = tex.box +local tex_count = tex.count +local tex_dimen = tex.dimen +local tex_lists = tex.lists local traverse = node.traverse -local traverseid = node.traverse_id or node.traverseid +local traverse_id = node.traverse_id or node.traverseid local vpack = node.vpack -- Misc. Constants local iffalse = token.create("iffalse") local iftrue = token.create("iftrue") local INFINITY = 10000 +local INSERT_CLASS_MULTIPLE = 1000 * 1000 +local INSERT_FIRST_MULTIPLE = 1000 local min_col_width = tex.sp("250pt") -local SINGLE_LINE = 50 local PAGE_MULTIPLE = 100 +local SINGLE_LINE = 50 ---[[ - Package/module initialization +--[[ Package/module initialization. + + Here, we replace any format/engine-specific variables/functions with some + generic equivalents. This way, we can write the rest of the module without + worrying about any format/engine differences. ]] -local attribute, - contrib_head, +local contrib_head, emergencystretch, info, + insert_attribute, max_cost, pagenum, + paragraph_attribute, stretch_order, warning if lmtx then -- LMTX has removed underscores from most of the Lua parts - debug_print("LMTX") + debug("LMTX") contrib_head = "contributehead" stretch_order = "stretchorder" else @@ -130,23 +149,27 @@ else end if context then - debug_print("ConTeXt") + debug("ConTeXt") warning = logs.reporter(lwc.name, "warning") local _info = logs.reporter(lwc.name, "info") + --[[ We don't want the info messages on the terminal, but ConTeXt doesn't + provide any logfile-only reporters, so we need this hack. + ]] info = function (text) logs.pushtarget("logfile") _info(text) logs.poptarget() end - attribute = attributes.public(lwc.name) - pagenum = function() return tex.count["realpageno"] end + paragraph_attribute = attributes.public(lwc.name .. "_paragraph") + insert_attribute = attributes.public(lwc.name .. "_insert") + pagenum = function() return tex_count["realpageno"] end -- Dimen names emergencystretch = "lwc_emergency_stretch" max_cost = "lwc_max_cost" elseif plain or latex or optex then - pagenum = function() return tex.count[0] end + pagenum = function() return tex_count[0] end -- Dimen names if tex.isdimen("g__lwc_emergencystretch_dim") then @@ -158,11 +181,11 @@ elseif plain or latex or optex then end if plain or latex then - debug_print("Plain/LaTeX") + debug("Plain/LaTeX") luatexbase.provides_module { name = lwc.name, - date = "2022/05/14", --%%dashdate - version = "2.1.1", --%%version + date = "2022/05/26", --%%slashdate + version = "2.1.2", --%%version description = [[ This module provides a LuaTeX-based solution to prevent @@ -172,13 +195,15 @@ paragraphs.]], } warning = function(str) luatexbase.module_warning(lwc.name, str) end info = function(str) luatexbase.module_info(lwc.name, str) end - attribute = luatexbase.new_attribute(lwc.name) + paragraph_attribute = luatexbase.new_attribute(lwc.name .. "_paragraph") + insert_attribute = luatexbase.new_attribute(lwc.name .. "_insert") elseif optex then - debug_print("OpTeX") + debug("OpTeX") warning = function(str) write_nl(lwc.name .. " Warning: " .. str) end info = function(str) write_nl("log", lwc.name .. " Info: " .. str) end - attribute = alloc.new_attribute(lwc.name) + paragraph_attribute = alloc.new_attribute(lwc.name .. "_paragraph") + insert_attribute = alloc.new_attribute(lwc.name .. "_insert") end else -- This shouldn't ever happen error [[Unsupported format. @@ -186,7 +211,17 @@ else -- This shouldn't ever happen Please use LaTeX, Plain TeX, ConTeXt or OpTeX.]] end -local paragraphs = {} -- List to hold the alternate paragraph versions +--[[ Table to hold the alternate paragraph versions. + + This is global(ish) mutable state, which isn't ideal, but any other way of + passing this data around would be even worse. + ]] +local paragraphs = {} +local inserts = {} + +--[[ + Function definitions + ]] --- Gets the current paragraph and page locations --- @return string @@ -194,11 +229,9 @@ local function get_location() return "At " .. pagenum() .. "/" .. #paragraphs end ---[[ - Function definitions - ]] ---- Prints the initial glyphs and glue of an hlist +--- Prints the starting glyphs and glue of an `hlist` +--- --- @param head node --- @return nil local function get_chars(head) @@ -220,10 +253,12 @@ local function get_chars(head) end end - debug_print(get_location(), chars) + debug(get_location(), chars) end ---- The "cost function" to use. See the manual. + +--- The "cost function" to use. Users can redefine this if they wish. +--- --- @param demerits number The demerits of the broken paragraph --- @param lines number The number of lines in the broken paragraph --- @return number The cost of the broken paragraph @@ -231,6 +266,7 @@ function lwc.paragraph_cost(demerits, lines) return demerits / math.sqrt(lines) end + --- Checks if the ConTeXt "grid snapping" is active --- @return boolean local function grid_mode_enabled() @@ -238,7 +274,9 @@ local function grid_mode_enabled() return token.create("ifgridsnapping").mode == iftrue.mode end ---- Gets the next node of a type/subtype in a node list + +--- Gets the next node of a specified type/subtype in a node list +--- --- @param head node The head of the node list --- @param id number The node type --- @param args table? @@ -247,13 +285,17 @@ end --- @return node local function next_of_type(head, id, args) args = args or {} + if lmtx or not args.reverse then - for n, subtype in traverseid(id, head, args.reverse) do + for n, subtype in traverse_id(id, head, args.reverse) do if (subtype == args.subtype) or (args.subtype == nil) then return n end end - else -- Only LMTX has the built-in backwards traverser + else + --[[ Only LMTX has the built-in backwards traverser, so we need to do it + ourselves here. + ]] while head do if head.id == id and (head.subtype == args.subtype or args.subtype == nil) @@ -265,39 +307,24 @@ local function next_of_type(head, id, args) end end ---- Saves each paragraph, but lengthened by 1 line ---- ---- Called by the `pre_linebreak_filter` callback ---- ---- @param head node ---- @return node -function lwc.save_paragraphs(head) - if (head.id ~= par_id and context) or -- Ensure that we were actually given a par - status.output_active -- Don't run during the output routine - then - return head - end - -- Prevent the "underfull hbox" warnings when we store a potential paragraph - local renable_box_warnings - if (context or optex) or - #luatexbase.callback_descriptions("hpack_quality") == 0 - then -- See #18 and michal-h21/linebreaker#3 - renable_box_warnings = true - lwc.callbacks.disable_box_warnings.enable() - end - - -- We need to return the unmodified head at the end, so we make a copy here - local new_head = copy(head) +--- Breaks a paragraph one line longer than natural +--- +--- @param head node The unbroken paragraph +--- @return node long_node The broken paragraph +--- @return table long_info An info table about the broken paragraph +local function long_paragraph(head) + -- We can't modify the original paragraph + head = copy_list(head) -- Prevent ultra-short last lines (\TeX{}Book p. 104), except with narrow columns -- Equivalent to \\parfillskip=0pt plus 0.8\\hsize local parfillskip - if lmtx or last(new_head).id ~= glue_id then + if lmtx or last(head).id ~= glue_id then -- LMTX does not automatically add the \\parfillskip glue parfillskip = new_node("glue", "parfillskip") else - parfillskip = last(new_head) + parfillskip = last(head) end if tex.hsize > min_col_width then @@ -305,35 +332,78 @@ function lwc.save_paragraphs(head) parfillskip.stretch = 0.8 * tex.hsize -- Last line must be at least 20% long end - if lmtx or last(new_head).id ~= glue_id then - last(new_head).next = parfillskip + if lmtx or last(head).id ~= glue_id then + last(head).next = parfillskip end -- Break the paragraph 1 line longer than natural - local long_node, long_info = tex.linebreak(new_head, { + return linebreak(head, { looseness = 1, - emergencystretch = tex.getdimen(emergencystretch), + emergencystretch = tex_dimen[emergencystretch], }) +end - -- Break the natural paragraph so we know how long it was - nat_head = copy(head) +--- Breaks a paragraph at its natural length +--- +--- @param head node The unbroken paragraph +--- @return table natural_info An info table about the broken paragraph +local function natural_paragraph(head) + -- We can't modify the original paragraph + head = copy_list(head) + + --[[ Contrary to the documentation, LMTX does not automatically add + the \\parfillskip glue before `pre_linebreak_filter`, so we need + to add it here so that our \\prevgraf comparisons are correct. + ]] if lmtx then parfillskip = new_node("glue", "parfillskip") parfillskip[stretch_order] = 1 parfillskip.stretch = 1 -- 0pt plus 1fil - last(nat_head).next = parfillskip + last(head).next = parfillskip end - local natural_node, natural_info = tex.linebreak(nat_head) - flush_list(natural_node) + -- Break the paragraph naturally to get \\prevgraf + local natural_node, natural_info = linebreak(head) + free_list(natural_node) + + return natural_info +end + + +--- Saves each paragraph, but lengthened by 1 line +--- +--- Called by the `pre_linebreak_filter` callback +--- +--- @param head node +--- @return node +function lwc.save_paragraphs(head) + if (head.id ~= par_id and context) or -- Ensure that we were actually given a par + status.output_active or -- Don't run during the output routine + tex.nest.ptr > 1 -- Don't run inside boxes + then + return head + end + + -- Prevent the "underfull hbox" warnings when we store a potential paragraph + local renable_box_warnings + if (context or optex) or + #luatexbase.callback_descriptions("hpack_quality") == 0 + then -- See #18 and michal-h21/linebreaker#3 + renable_box_warnings = true + lwc.callbacks.disable_box_warnings.enable() + end + + long_node, long_info = long_paragraph(head) + + natural_info = natural_paragraph(head) if renable_box_warnings then lwc.callbacks.disable_box_warnings.disable() end if not grid_mode_enabled() then - -- Offset the accumulated \\prevdepth + -- Offset the \\prevdepth differences between natural and long local prevdepth = new_node("glue") prevdepth.width = natural_info.prevdepth - long_info.prevdepth last(long_node).next = prevdepth @@ -352,14 +422,14 @@ function lwc.save_paragraphs(head) -- Print some debugging information get_chars(head) - debug_print(get_location(), "nat lines " .. natural_info.prevgraf) - debug_print( + debug(get_location(), "nat lines " .. natural_info.prevgraf) + debug( get_location(), "nat cost " .. lwc.paragraph_cost(natural_info.demerits, natural_info.prevgraf) ) - debug_print(get_location(), "long lines " .. long_info.prevgraf) - debug_print( + debug(get_location(), "long lines " .. long_info.prevgraf) + debug( get_location(), "long cost " .. lwc.paragraph_cost(long_info.demerits, long_info.prevgraf) @@ -369,84 +439,117 @@ function lwc.save_paragraphs(head) return head end + --- Tags the beginning and the end of each paragraph as it is added to the page. --- --- We add an attribute to the first and last node of each paragraph. The ID is --- some arbitrary number for \lwc/, and the value corresponds to the ---- paragraphs index, which is negated for the end of the paragraph. Called by the ---- `post_linebreak_filter` callback. +--- paragraphs index, which is negated for the end of the paragraph. --- --- @param head node ---- @return node -function lwc.mark_paragraphs(head) +--- @return nil +local function mark_paragraphs(head) + -- Tag the paragraphs if not status.output_active then -- Don't run during the output routine -- Get the start and end of the paragraph local top_para = next_of_type(head, hlist_id, { subtype = line_subid }) local bottom_para = last(head) + while bottom_para.id == insert_id do + bottom_para = bottom_para.prev + end + if top_para ~= bottom_para then set_attribute( top_para, - attribute, + paragraph_attribute, #paragraphs + (PAGE_MULTIPLE * pagenum()) ) set_attribute( bottom_para, - attribute, + paragraph_attribute, -1 * (#paragraphs + (PAGE_MULTIPLE * pagenum())) ) else -- We need a special tag for a 1-line paragraph since the node can only - -- have one attribute value + -- have a single attribute value set_attribute( top_para, - attribute, + paragraph_attribute, #paragraphs + (PAGE_MULTIPLE * pagenum()) + SINGLE_LINE ) end end - - return head end ---- A "safe" version of the last/slide function. + +--- Tags the each line with the indices of any corresponding inserts. --- ---- Sometimes the node list can form a loop. Since there is no last element ---- of a looped linked-list, the `last()` function will never terminate. This ---- function provides a "safe" version of the `last()` function that will break ---- the loop at the end if the list is circular. Called by the `pre_output_filter` ---- callback. +--- We need to tag the first element of the hlist before the any insert nodes +--- since the insert nodes are removed before `pre_output_filter` gets called. --- ---- @param head node The start of a node list ---- @return node The last node in a list -local function safe_last(head) - local ids = {} - local prev - - while head.next do - local id = node_id(head) +--- @param head node +--- @return nil +local function mark_inserts(head) + local insert_indices = {} + for insert in traverse_id(insert_id, head) do + -- Save the found insert nodes for later + inserts[#inserts+1] = copy(insert) - if ids[id] then - warning [[Circular node list detected! -This should never happen. I'll try and -recover, but your output may be corrupted. -(Internal Error)]] - prev.next = nil - debug_print("safe_last", node.type(head.id) .. " " .. node.type(prev.id)) + -- Tag the insert's content so that we can find it later + set_attribute(insert.list, insert_attribute, #inserts) - return prev + for n in traverse(insert.list.next) do + set_attribute(n, insert_attribute, -1 * #inserts) end - ids[id] = true - head.prev = prev - prev = head - head = head.next + --[[ Each hlist/line can have multiple inserts, but so we can't just tag + the hlist as we go. Instead, we need save up all of their indices, + then tag the hlist with the first and last indices. + ]] + insert_indices[#insert_indices+1] = #inserts + + if not insert.next or + insert.next.id ~= insert_id + then + local hlist_before = next_of_type(insert, hlist_id, { reverse = true} ) + + --[[ We tag the first element of the hlist/line with an integer + that holds the insert class and the first and last indices + of the inserts contained in the line. This won't work if + the line has multiple classes of inserts, but I don't think + that happens in real-world documents. + ]] + set_attribute( + hlist_before.list, + insert_attribute, + insert.subtype * INSERT_CLASS_MULTIPLE + + insert_indices[1] * INSERT_FIRST_MULTIPLE + + insert_indices[#insert_indices] + ) + + -- Clear the indices to prepare for the next line + insert_indices = {} + end end +end + + +--- Saves the inserts and tags a typeset paragraph. Called by the +--- `post_linebreak_filter` callback. +--- +--- @param head node +--- @return node +function lwc.mark_paragraphs(head) + mark_paragraphs(head) + mark_inserts(head) return head end + --- Checks to see if a penalty matches the widow/orphan/broken penalties +--- --- @param penalty number --- @return boolean function is_matching_penalty(penalty) @@ -455,15 +558,9 @@ function is_matching_penalty(penalty) local displaywidowpenalty = tex.displaywidowpenalty local brokenpenalty = tex.brokenpenalty - --[[ - We only need to process pages that have orphans or widows. If `paragraphs` - is empty, then there is nothing that we can do. - - The list of penalties is from: - https://tug.org/TUGboat/tb39-3/tb123mitt-widows-code.pdf#subsection.0.2.1 - ]] penalty = penalty - tex.interlinepenalty + -- https://tug.org/TUGboat/tb39-3/tb123mitt-widows-code.pdf#subsection.0.2.1 return penalty ~= 0 and penalty < INFINITY and ( penalty == widowpenalty or @@ -480,143 +577,233 @@ function is_matching_penalty(penalty) ) end ---- Remove the widows and orphans from the page, just after the output routine. ---- ---- This function holds the "meat" of the module. It is called just after the ---- end of the output routine, before the page is shipped out. If the output ---- penalty indicates that the page was broken at a widow or an orphan, we ---- replace one paragraph with the same paragraph, but lengthened by one line. ---- Then, we can push the bottom line of the page to the next page. ---- ---- @param head node ---- @return node -function lwc.remove_widows(head) - local head_save = head -- Save the start of the `head` linked-list - debug_print("outputpenalty", tex.outputpenalty .. " " .. #paragraphs) +--- Reset any state saved between pages +--- +--- @return nil +local function reset_state() + paragraphs = {} - if not is_matching_penalty(tex.outputpenalty) or - #paragraphs == 0 - then - paragraphs = {} - return head_save + for _, insert in ipairs(inserts) do + free(insert) end - info("Widow/orphan/broken hyphen detected. Attempting to remove") + inserts = {} +end - local vsize = tex.dimen.vsize - local orig_height_diff = vpack(head).height - vsize - --[[ - Find the paragraph on the page with the least cost. - ]] - local paragraph_index = 1 - local best_cost = paragraphs[paragraph_index].cost +--- When we are unable to remove a widow/orphan, print a warning +--- +--- @return nil +local function remove_widows_fail() + warning("Widow/Orphan/broken hyphen NOT removed on page " .. pagenum()) + reset_state() +end + + +--- Finds the first and last paragraphs present on a page +--- +--- @param head node The node representing the start of the page +--- @return number first_index The index of the first paragraph on the page in +--- the `paragraphs` table +--- @return number last_index The index of the last paragraph on the page in the +--- `paragraphs` table +local function first_last_paragraphs(head) + local first_index, last_index - local last_paragraph - local head_last = last(head) -- Find the last paragraph on the page, starting at the end, heading in reverse - while head_last do - local value = getattribute(head_last, attribute) + local n = last(head) + while n do + local value = get_attribute(n, paragraph_attribute) if value then - last_paragraph = value % PAGE_MULTIPLE + last_index = value % PAGE_MULTIPLE break end - head_last = head_last.prev + n = n.prev end - local first_paragraph -- Find the first paragraph on the page, from the top - local first_attribute_val, first_attribute_head = find_attribute(head, attribute) - if first_attribute_val // 100 == pagenum() - 1 then - -- If the first complete paragraph on the page was initially broken on the - -- previous page, then we can't expand it here so we need to skip it. - first_paragraph = find_attribute( - first_attribute_head.next, - attribute + local first_val, first_head = find_attribute(head, paragraph_attribute) + if first_val // PAGE_MULTIPLE == pagenum() - 1 then + --[[ If the first complete paragraph on the page was initially broken on the + previous page, then we can't expand it here so we need to skip it. + ]] + first_index = find_attribute( + first_head.next, + paragraph_attribute ) % PAGE_MULTIPLE else - first_paragraph = first_attribute_val % PAGE_MULTIPLE + first_index = first_val % PAGE_MULTIPLE end + return first_index, last_index +end + + +--- Selects the "best" paragraph on the page to expand +--- +--- @param head node The node representing the start of the page +--- @return number? best_index The index of the paragraph to expand in the +--- `paragraphs` table +local function best_paragraph(head) + local first_paragraph_index, last_paragraph_index = first_last_paragraphs(head) + + -- Find the paragraph on the page with the least cost. + local best_index = 1 + local best_cost = paragraphs[best_index].cost + -- We find the current "best" replacement, then free the unused ones - for i, paragraph in pairs(paragraphs) do + for index, paragraph in pairs(paragraphs) do if paragraph.cost < best_cost and - i < last_paragraph and - i >= first_paragraph + index < last_paragraph_index and + index >= first_paragraph_index then - -- Clear the old best paragraph - flush_list(paragraphs[paragraph_index].node) - paragraphs[paragraph_index].node = nil + -- Free the old best paragraph + free_list(paragraphs[best_index].node) + paragraphs[best_index].node = nil -- Set the new best paragraph - paragraph_index, best_cost = i, paragraph.cost - elseif i > 1 then + best_index, best_cost = index, paragraph.cost + elseif index > 1 then -- Not sure why `i > 1` is required? - flush_list(paragraph.node) + free_list(paragraph.node) paragraph.node = nil end end - debug_print( + debug( "selected para", - pagenum() .. - "/" .. - paragraph_index .. - " (" .. - best_cost .. - ")" + pagenum() .. "/" .. best_index .. " (" .. best_cost .. ")" ) - if best_cost > tex.getcount(max_cost) or - paragraph_index == last_paragraph + if best_cost > tex_count[max_cost] or + best_index == last_paragraph_index then - -- If the best replacement is too bad, we can't do anything - warning("Widow/Orphan/broken hyphen NOT removed on page " .. pagenum()) - paragraphs = {} - return head_save + return nil + else + return best_index end +end - local target_node = paragraphs[paragraph_index].node +--- Gets any inserts present in the moved line +--- +--- @param last_line node The moved last line +--- @return table inserts A list of the present inserts +local function get_inserts(last_line) + local selected_inserts = {} + + local n = last_line.list + while n do -- Iterate through the last line + local line_value + line_value, n = find_attribute(n, insert_attribute) + + if not n then + break + end + + --[[ With LuaMetaTeX, the subtype of `insert` nodes is always zero, + so we cannot detect their class therefore we can't fix any moved + footnotes. + ]] + if lmtx then + warning("!!!Incorrect footnotes on page " .. pagenum() .. "!!!") + return {} + end + + -- Demux the insert values + local class = line_value // INSERT_CLASS_MULTIPLE + local first_index = (line_value % INSERT_CLASS_MULTIPLE) // INSERT_FIRST_MULTIPLE + local last_index = line_value % INSERT_FIRST_MULTIPLE + + -- Get the output box containing the insert boxes + local insert_box = tex_box[class] + + local m = insert_box.list + while m do -- Iterate through the insert box + local box_value + box_value, m = find_attribute(m, insert_attribute) + + if not m then + break + end + + if abs(box_value) >= first_index and + abs(box_value) <= last_index + then + -- Remove the respective contents from the insert box + insert_box.list = node.remove(insert_box.list, m) + + if box_value > 0 then + selected_inserts[#selected_inserts + 1] = copy(inserts[box_value]) + end + end + + m = m.next + end + + if not insert_box.list then + tex_box[class] = nil + end + + n = n.next + end + + if #selected_inserts ~= 0 then + info("Moving footnotes on page " .. pagenum()) + end + + return selected_inserts +end + + +lwc.nobreak_behaviour = "keep" +--- Moves the last line of the page onto the following page. +--- +--- This is the most complicated function of the module since it needs to +--- look back to see if there is a heading preceding the last line, then it does +--- some low-level node shuffling. +--- +--- @param head node The node representing the start of the page +--- @return boolean success +local function move_last_line(head) -- Start of final paragraph - debug_print("remove_widows", "moving last line") + debug("remove_widows", "moving last line") -- Here we check to see if the widow/orphan was preceded by a large penalty - head = last(head_save).prev local big_penalty_found, last_line, hlist_head - while head do - if head.id == glue_id then + local n = last(head).prev + while n do + if n.id == glue_id then -- Ignore any glue nodes - elseif head.id == penalty_id and head.penalty >= INFINITY then + elseif n.id == penalty_id and n.penalty >= INFINITY then -- Infinite break penalty big_penalty_found = true - elseif big_penalty_found and head.id == hlist_id then + elseif big_penalty_found and n.id == hlist_id then -- Line before the penalty if lwc.nobreak_behaviour == "keep" then - hlist_head = head + hlist_head = n big_penalty_found = false elseif lwc.nobreak_behaviour == "split" then - head = last(head_save) + n = last(head) break elseif lwc.nobreak_behaviour == "warn" then - warning("Widow/Orphan/broken hyphen NOT removed on page " .. pagenum()) - paragraphs = {} - return head_save + debug("last line", "heading found") + return false end else -- Not found if hlist_head then - head = hlist_head + n = hlist_head else - head = last(head_save) + n = last(head) end break end - head = head.prev + n = n.prev end - local potential_penalty = head.prev.prev + local potential_penalty = n.prev.prev if potential_penalty and potential_penalty.id == penalty_id and @@ -626,43 +813,68 @@ function lwc.remove_widows(head) warning("Making a new widow/orphan/broken hyphen on page " .. pagenum()) end + last_line = copy_list(n) - last_line = copy(head) - last(last_line).next = copy(tex.lists[contrib_head]) + -- Reinsert any inserts originally present in this moved line + local selected_inserts = get_inserts(last_line) + for _, insert in ipairs(selected_inserts) do + last(last_line).next = insert + end + + -- Add back in the content from the next page + last(last_line).next = copy_list(tex_lists[contrib_head]) + + n.prev.prev.next = nil + + -- Set the content of the next page + last(last_line) + tex_lists[contrib_head] = last_line + + return true +end - head.prev.prev.next = nil - -- Move the last line to the next page - tex.lists[contrib_head] = last_line +--- Replace the chosen paragraph with its expanded version. +--- +--- This is the "core function" of the module since it is what ultimately causes +--- the expansion to occur. +--- +--- @param head node +--- @param paragraph_index number +local function replace_paragraph(head, paragraph_index) + local target_node = paragraphs[paragraph_index].node local free_next_nodes = false + local start_found = false + local end_found = false + -- Loop through all of the nodes on the page with the lwc attribute - head = head_save - while head do + local n = head + while n do local value - value, head = find_attribute(head, attribute) + value, n = find_attribute(n, paragraph_attribute) - if not head then + if not n then break end - debug_print("remove_widows", "found " .. value) + debug("remove_widows", "found " .. value) -- Insert the start of the replacement paragraph if value == paragraph_index + (PAGE_MULTIPLE * pagenum()) or value == paragraph_index + (PAGE_MULTIPLE * pagenum()) + SINGLE_LINE then - debug_print("remove_widows", "replacement start") - safe_last(target_node) -- Remove any loops + debug("remove_widows", "replacement start") + start_found = true -- Fix the `\\baselineskip` glue between paragraphs height_difference = ( - next_of_type(head, hlist_id, { subtype = line_subid }).height - + next_of_type(n, hlist_id, { subtype = line_subid }).height - next_of_type(target_node, hlist_id, { subtype = line_subid }).height ) local prev_bls = next_of_type( - head, + n, glue_id, { subtype = baselineskip_subid, reverse = true } ) @@ -671,7 +883,7 @@ function lwc.remove_widows(head) prev_bls.width = prev_bls.width + height_difference end - head.prev.next = target_node + n.prev.next = target_node free_next_nodes = true end @@ -679,45 +891,101 @@ function lwc.remove_widows(head) if value == -1 * (paragraph_index + (PAGE_MULTIPLE * pagenum())) or value == paragraph_index + (PAGE_MULTIPLE * pagenum()) + SINGLE_LINE then - debug_print("remove_widows", "replacement end") - local target_node_last = safe_last(target_node) + debug("remove_widows", "replacement end") + end_found = true + + local target_node_last = last(target_node) if grid_mode_enabled() then -- Account for the difference in depth local after_glue = new_node("glue") - after_glue.width = head.depth - target_node_last.depth + after_glue.width = n.depth - target_node_last.depth target_node_last.next = after_glue - after_glue.next = head.next + after_glue.next = n.next else - target_node_last.next = head.next + target_node_last.next = n.next end break end if free_next_nodes then - head = free(head) + n = free(n) else - head = head.next + n = n.next end end - local new_height_diff = vpack(head_save).height - vsize - -- We need the original height discrepancy in case there are \\vfill's - local net_height_diff = orig_height_diff - new_height_diff + if not (start_found and end_found) then + warning("Paragraph NOT expanded on page " .. pagenum()) + end +end + + +--- Remove the widows and orphans from the page, just after the output routine. +--- +--- This is called just after the end of the output routine, before the page is +--- shipped out. If the output penalty indicates that the page was broken at a +--- widow or an orphan, we replace one paragraph with the same paragraph, but +--- lengthened by one line. Then, we can push the bottom line of the page to the +--- next page. +--- +--- @param head node +--- @return node +function lwc.remove_widows(head) + debug("outputpenalty", tex.outputpenalty .. " " .. #paragraphs) + + -- See if there is a widow/orphan for us to remove + if not is_matching_penalty(tex.outputpenalty) then + reset_state() + return head + end + + info("Widow/orphan/broken hyphen detected. Attempting to remove") + + -- Nothing that we can do if there aren't any paragraphs available to expand + if #paragraphs == 0 then + remove_widows_fail() + return head + end + + -- Check the original height of \\box255 + local vsize = tex_dimen.vsize + local orig_height_diff = vpack(head).height - vsize + + -- Find the paragraph to expand + local paragraph_index = best_paragraph(head) + + if not paragraph_index then + remove_widows_fail() + return head + end + + -- Move the last line of the page to the next page + if not move_last_line(head) then + remove_widows_fail() + return head + end + + -- Replace the chosen paragraph with its expanded version + replace_paragraph(head, paragraph_index) --[[ The final \\box255 needs to be exactly \\vsize tall to avoid over/underfull box warnings, so we correct any discrepancies here. ]] + local new_height_diff = vpack(head).height - vsize + -- We need the original height discrepancy in case there are \\vfill's + local net_height_diff = orig_height_diff - new_height_diff + if abs(net_height_diff) > 0 and -- A difference larger than 0.25\\baselineskip is probably not from \lwc/ abs(net_height_diff) < tex.skip.baselineskip.width / 4 then local bottom_glue = new_node("glue") bottom_glue.width = net_height_diff - last(head_save).next = bottom_glue + last(head).next = bottom_glue end info( @@ -727,12 +995,14 @@ function lwc.remove_widows(head) .. pagenum() ) - paragraphs = {} -- Clear paragraphs array at the end of the page + reset_state() - return head_save + return head end + --- Create a table of functions to enable or disable a given callback +--- --- @param t table Parameters of the callback to create --- callback: string = The \LuaTeX{} callback name --- func: function = The function to call @@ -756,8 +1026,9 @@ local function register_callback(t) } elseif context and not t.lowlevel then return { - -- Register the callback when the table is created, - -- but activate it when `enable()` is called. + --[[ Register the callback when the table is created, + but activate it when `enable()` is called. + ]] enable = nodes.tasks.appendaction(t.category, t.position, "lwc." .. t.name) or function() nodes.tasks.enableaction(t.category, "lwc." .. t.name) @@ -767,13 +1038,12 @@ local function register_callback(t) end, } elseif context and t.lowlevel then - --[[ - Some of the callbacks in \ConTeXt{} have no associated "actions". Unlike - with \LuaTeX{}base, \ConTeXt{} leaves some \LuaTeX{} callbacks unregistered - and unfrozen. Because of this, we need to register some callbacks at the - engine level. This is fragile though, because a future \ConTeXt{} update - may decide to register one of these functions, in which case - \lwc/ will crash with a cryptic error message. + --[[ Some of the callbacks in \ConTeXt{} have no associated "actions". Unlike + with \LuaTeX{}base, \ConTeXt{} leaves some \LuaTeX{} callbacks unregistered + and unfrozen. Because of this, we need to register some callbacks at the + engine level. This is fragile though, because a future \ConTeXt{} update + may decide to register one of these functions, in which case + \lwc/ will crash with a cryptic error message. ]] return { enable = function() callback.register(t.callback, t.func) end, @@ -791,6 +1061,7 @@ local function register_callback(t) end end + -- Add all of the callbacks lwc.callbacks = { disable_box_warnings = register_callback({ @@ -822,51 +1093,53 @@ lwc.callbacks = { } -local enabled = false ---- Enable the paragraph callbacks +local lwc_enabled = false +--- Enables the paragraph callbacks function lwc.enable_callbacks() - debug_print("callbacks", "enabling") - if not enabled then + debug("callbacks", "enabling") + if not lwc_enabled then lwc.callbacks.save_paragraphs.enable() lwc.callbacks.mark_paragraphs.enable() - enabled = true + lwc_enabled = true else info("Already enabled") end end ---- Disable the paragraph callbacks + +--- Disables the paragraph callbacks function lwc.disable_callbacks() - debug_print("callbacks", "disabling") - if enabled then + debug("callbacks", "disabling") + if lwc_enabled then lwc.callbacks.save_paragraphs.disable() lwc.callbacks.mark_paragraphs.disable() - --[[ - We do \emph{not} disable `remove_widows` callback, since we still want - to expand any of the previously-saved paragraphs if we hit an orphan - or a widow. + --[[ We do \emph{not} disable `remove_widows` callback, since we still want + to expand any of the previously-saved paragraphs if we hit an orphan + or a widow. ]] - enabled = false + lwc_enabled = false else info("Already disabled") end end function lwc.if_lwc_enabled() - debug_print("iflwc") - if enabled then + debug("iflwc") + if lwc_enabled then insert_token(iftrue) else insert_token(iffalse) end end + --- Mangles a macro name so that it's suitable for a specific format +--- --- @param name string The plain name --- @param args table The TeX types of the function arguments ---- @return string The mangled name +--- @return string name The mangled name local function mangle_name(name, args) if plain then return "lwc@" .. name:gsub("_", "@") @@ -879,7 +1152,9 @@ local function mangle_name(name, args) end end + --- Creates a TeX command that evaluates a Lua function +--- --- @param name string The name of the csname to define --- @param func function --- @param args table The TeX types of the function arguments @@ -921,6 +1196,7 @@ local function register_tex_cmd(name, func, args) end end + register_tex_cmd("if_enabled", lwc.if_lwc_enabled, {}) register_tex_cmd("enable", lwc.enable_callbacks, {}) register_tex_cmd("disable", lwc.disable_callbacks, {}) @@ -954,13 +1230,15 @@ register_tex_cmd( --- This uses the Lua `debug` library to internally modify the log upvalue in the --- `add_to_callback` function. This is almost certainly a terrible idea, but I don't --- know of a better way. +--- +--- @return nil local function silence_luatexbase() - local nups = debug.getinfo(luatexbase.add_to_callback).nups + local nups = debug_lib.getinfo(luatexbase.add_to_callback).nups for i = 1, nups do - local name, func = debug.getupvalue(luatexbase.add_to_callback, i) + local name, func = debug_lib.getupvalue(luatexbase.add_to_callback, i) if name == "luatexbase_log" then - debug.setupvalue( + debug_lib.setupvalue( luatexbase.add_to_callback, i, function(text) @@ -976,11 +1254,18 @@ local function silence_luatexbase() end end --- Activate \lwc/ -if plain or latex then + +--[[ Call `silence_luatexbase` in Plain and LaTeX, unless the undocmented global + `LWC_NO_DEBUG` is set. We provide this opt-out in case something goes awry + with the `debug` library calls. + ]] +if (plain or latex) and + not LWC_NO_DEBUG --- @diagnostic disable-line +then silence_luatexbase() end +-- Activate \lwc/ lwc.callbacks.remove_widows.enable() return lwc -- cgit v1.2.3