diff options
Diffstat (limited to 'macros/luatex/generic/lua-widow-control/lua-widow-control.lua')
-rw-r--r-- | macros/luatex/generic/lua-widow-control/lua-widow-control.lua | 824 |
1 files changed, 569 insertions, 255 deletions
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 1ef86174c4..59ae84ee3e 100644 --- a/macros/luatex/generic/lua-widow-control/lua-widow-control.lua +++ b/macros/luatex/generic/lua-widow-control/lua-widow-control.lua @@ -44,7 +44,14 @@ local function debug(title, text) local filler = 15 - #title if text then - write_nl(write_log, "LWC (" .. title .. string_rep(" ", filler) .. "): " .. text) + write_nl( + write_log, + "LWC (" .. + title .. + string_rep(" ", filler) .. + "): " .. + text + ) else write_nl(write_log, "LWC: " .. string_rep(" ", 18) .. title) end @@ -52,24 +59,28 @@ 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 - detect the format name then set some flags for later processing. + \lwc/ is intended to be format-agonistic. It only runs on Lua\TeX{} and + LuaMeta\TeX{}, but there are still some slight differences between formats. + Here, we detect the format name then set some flags for later processing. ]] local format = tex.formatname local context, latex, plain, optex, lmtx +if status.luatex_engine == "luametatex" then + lmtx = true +end + if format:find("cont") then -- cont-en, cont-fr, cont-nl, ... context = true - if status.luatex_engine == "luametatex" then - lmtx = true - end elseif format:find("latex") then -- lualatex, lualatex-dev, ... latex = true -elseif format == "luatex" or format == "luahbtex" then -- Plain +elseif format == "luatex" or + format == "luahbtex" or + format:find("plain") +then -- Plain plain = true elseif format:find("optex") then -- OpTeX - optex = true + optex = _G.optex end --[[ @@ -89,22 +100,35 @@ local line_subid = 1 local linebreakpenalty_subid = 1 local par_id = id_from_name("par") or id_from_name("local_par") local penalty_id = id_from_name("penalty") +local parfill_subids = { + parfillleftskip = 17, + parfillrightskip = 16, + parinitleftskip = 19, + parinitrightskip = 18, +} +local vlist_id = id_from_name("vlist") -- Local versions of globals local abs = math.abs local copy = node.copy local copy_list = node.copy_list or node.copylist +local effective_glue = node.effective_glue or node.effectiveglue local find_attribute = node.find_attribute or node.findattribute local free = node.free local free_list = node.flush_list or node.flushlist local get_attribute = node.get_attribute or node.getattribute +local hpack = node.hpack local insert_token = token.put_next or token.putnext +local is_node = node.is_node or node.isnode local last = node.slide local linebreak = tex.linebreak local new_node = node.new local remove = node.remove local set_attribute = node.set_attribute or node.setattribute -local string_char = string.char +local str_byte = string.byte +local str_char = string.char +local str_format = string.format +local subtype = node.subtype local tex_box = tex.box local tex_count = tex.count local tex_dimen = tex.dimen @@ -119,8 +143,6 @@ local iftrue = token.create("iftrue") local INFINITY = 10000 local INSERT_CLASS_MULTIPLE = 1000 * 1000 local INSERT_FIRST_MULTIPLE = 1000 -local llap_offset = math.max(tex.dimen.parindent, tex.sp("12pt")) -local min_col_width = tex.sp("250pt") local PAGE_MULTIPLE = 100 local SINGLE_LINE = 50 @@ -128,8 +150,10 @@ lwc.colours = { expanded = {0.00, 0.70, 0.25}, failure = {0.90, 0.00, 0.25}, moved = {0.25, 0.25, 1.00}, + cost = {0.50, 0.50, 0.50}, } + --[[ Package/module initialization. Here, we replace any format/engine-specific variables/functions with some @@ -137,13 +161,15 @@ lwc.colours = { worrying about any format/engine differences. ]] local contrib_head, + draft_offset, emergencystretch, hold_head, info, insert_attribute, max_cost, - pagenum, + page_head, paragraph_attribute, + set_whatsit_field, shrink_order, stretch_order, warning @@ -155,11 +181,15 @@ if lmtx then shrink_order = "shrinkorder" stretch_order = "stretchorder" hold_head = "holdhead" + page_head = "pagehead" + set_whatsit_field = node.setwhatsitfield else contrib_head = "contrib_head" shrink_order = "shrink_order" stretch_order = "stretch_order" hold_head = "hold_head" + page_head = "page_head" + set_whatsit_field = node.setfield end if context then @@ -177,20 +207,20 @@ if context then end paragraph_attribute = attributes.public(lwc.name .. "_paragraph") insert_attribute = attributes.public(lwc.name .. "_insert") - pagenum = function() return tex_count["realpageno"] end - -- Dimen names + -- Dimen/count names emergencystretch = "lwc_emergency_stretch" + draft_offset = "lwc_draft_offset" max_cost = "lwc_max_cost" elseif plain or latex or optex then - pagenum = function() return tex_count[0] end - -- Dimen names if tex.isdimen("g__lwc_emergencystretch_dim") then emergencystretch = "g__lwc_emergencystretch_dim" + draft_offset = "g__lwc_draftoffset_dim" max_cost = "g__lwc_maxcost_int" else emergencystretch = "lwcemergencystretch" + draft_offset = "lwcdraftoffset" max_cost = "lwcmaxcost" end @@ -198,8 +228,8 @@ elseif plain or latex or optex then debug("Plain/LaTeX") luatexbase.provides_module { name = lwc.name, - date = "2022/08/23", --%%slashdate - version = "2.2.2", --%%version + date = "2022/11/22", --%%slashdate + version = "3.0.0", --%%version description = [[ This module provides a LuaTeX-based solution to prevent @@ -225,30 +255,46 @@ else -- This shouldn't ever happen Please use LaTeX, Plain TeX, ConTeXt or OpTeX.]] end +-- We can't get the value of \\horigin from Lua, but we can guess it +-- based on the format. +local horigin +if optex or (lmtx and context) then + horigin = 0 +else + horigin = tex.sp("1in") +end + +-- Plain is the only format without a `pre_shipout_filter` +if plain then + luatexbase.create_callback('pre_shipout_filter', 'list') +end + + --[[ Select the fonts - We want to use cmr7 for the draft mode cost displays, and the easiest + We want to use cmr6 for the draft mode cost displays, and the easiest way to do so is to just hardcode the font id's. This relies on some - implementation details; however, it is very unlikely to ever be an issue + implementation details; however, it is very unlikely to ever be an issue. ]] local SMALL_FONT if plain then - SMALL_FONT = 4 + SMALL_FONT = 5 elseif latex then - SMALL_FONT = 7 + SMALL_FONT = 6 elseif optex then - SMALL_FONT = 7 + SMALL_FONT = 8 elseif context then - SMALL_FONT = 3 + SMALL_FONT = fonts.definers.define({ + name = "LMRoman6-Regular", + size = tex.sp("6pt"), + }) end ---[[ 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 = {} +-- Global variables +local paragraphs = {} -- The expanded paragraphs on each page +local inserts = {} -- Copies of all the inserts on each page +local costs = {} -- All of the paragraph costs for the document +local pagenum = 1 -- The current page/column number --[[ Function definitions ]] @@ -256,11 +302,13 @@ local inserts = {} --- Gets the current paragraph and page locations --- @return string local function get_location() - return "At " .. pagenum() .. "/" .. #paragraphs + return "At " .. pagenum .. "/" .. #paragraphs end ---- Prints the starting glyphs and glue of an `hlist` +--- Prints the starting glyphs and glue of an `hlist`. +--- +--- Useful for debugging purposes. --- --- @param head node --- @return nil @@ -271,7 +319,7 @@ local function get_chars(head) for n in traverse(head) do if n.id == glyph_id then if n.char < 127 then -- Only ASCII - chars = chars .. string_char(n.char) + chars = chars .. str_char(n.char) else chars = chars .. "#" -- Replacement for an unknown glyph end @@ -291,8 +339,11 @@ end --- --- @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 -function lwc.paragraph_cost(demerits, lines) +--- @param nat_demerits number The demerits of the naturally-broken paragraph +--- @param nat_lines number The number of lines in the naturally-broken paragraph +--- @param head node The head of the broken paragraph +--- @return number cost The cost of the broken paragraph +function lwc.paragraph_cost(demerits, lines, nat_demerits, nat_lines, head) return demerits / math.sqrt(lines) end @@ -324,9 +375,8 @@ local function next_of_type(head, id, args) end end else - --[[ Only LMTX has the built-in backwards traverser, so we need to do it - ourselves here. - ]] + -- Only LMTX has the built-in backwards traverser, so we need to do + -- it manually otherwise. while head do if head.id == id and (head.subtype == args.subtype or args.subtype == nil) @@ -346,33 +396,76 @@ local function next_of_type(head, id, args) end +--- Ensures that a paragraph is ready to be broken +--- +--- Only applies to LuaMetaTeX +--- +--- @param head node +--- @return nil +local function prepare_linebreak(head) + if not lmtx then + return + end + + -- See how many of the par[left/right][init/fill]skips we have + local parfills = {} + local count = 0 + for name, subid in pairs(parfill_subids) do + parfills[name] = next_of_type(head, glue_id, { subtype = subid }) + if parfills[name] then + count = count + 1 + end + end + + if count == 0 then + -- Usual case + tex.preparelinebreak(head) + elseif count == 4 then + -- Already prepared for some reason, ignored + else + -- Uh oh + warning("Weird par(fill/init)skips found!") + tex.preparelinebreak(head) -- Try to fix it + end +end + + --- Breaks a paragraph one line longer than natural --- --- @param head node The unbroken paragraph +--- @param parfillskip table<number> The {width, stretch, shrink, +--- stretch_order, shrink_order} to set +--- for the \\parfillskip --- @return node long_node The broken paragraph --- @return table long_info An info table about the broken paragraph -local function long_paragraph(head) +local function long_paragraph(head, parfillskip) -- We can't modify the original paragraph head = copy_list(head) - if lmtx then - tex.preparelinebreak(head) - end - - -- Prevent ultra-short last lines (\TeX{}Book p. 104), except with narrow columns - -- Equivalent to \\parfillskip=0pt plus 0.8\\hsize - local parfillskip = last(head) + prepare_linebreak(head) - if tex.hsize > min_col_width then - parfillskip[stretch_order] = 0 - parfillskip.stretch = 0.8 * tex.hsize -- Last line must be at least 20% long - end + -- TODO node.setglue is broken in LMTX, so we have to do this manually + local n = last(head) + n.width = parfillskip[1] + n.stretch = parfillskip[2] + n.shrink = parfillskip[3] + n[stretch_order] = parfillskip[4] + n[shrink_order] = parfillskip[5] -- Break the paragraph 1 line longer than natural - return linebreak(head, { + local long_node, long_info = linebreak(head, { looseness = 1, emergencystretch = tex_dimen[emergencystretch], }) + + -- Mark the last line for the costs display + set_attribute( + last(long_node), + paragraph_attribute, + -1 * (#paragraphs + 1 + (PAGE_MULTIPLE * pagenum)) + ) + + return long_node, long_info end @@ -384,9 +477,7 @@ local function natural_paragraph(head) -- We can't modify the original paragraph head = copy_list(head) - if lmtx then - tex.preparelinebreak(head) - end + prepare_linebreak(head) -- Break the paragraph naturally to get \\prevgraf local natural_node, natural_info = linebreak(head) @@ -396,88 +487,50 @@ local function natural_paragraph(head) end -lwc.draft_mode = false +local show_colours = false --- Changes the text colour in a node list if draft mode is active --- --- @param head node The first node to colour ---- @param colour table<number> A 3-tuple of RGB values +--- @param colour string The name of a colour in `lwc.colours` --- @return node head The coloured node local function colour_list(head, colour) - if not lwc.draft_mode then + if not show_colours then return head end - -- Adapted from https://tex.stackexchange.com/a/372437 - -- \\pdfextension colorstack is ignored in LMTX - local start_colour = new_node("whatsit", "pdf_colorstack") - start_colour.stack = 0 - start_colour.command = 1 - start_colour.data = string.format("%.2f %.2f %.2f rg", table.unpack(colour)) - - local end_colour = new_node("whatsit", "pdf_colorstack") - end_colour.stack = 0 - end_colour.command = 2 - - start_colour.next = head - last(head).next = end_colour - - return start_colour -end + local pdf_colour = str_format( + "%.2f %.2f %.2f rg", + table.unpack(lwc.colours[colour]) + ) + if optex and optex.set_node_color then + for n in node.traverse(head) do + optex.set_node_color(n, pdf_colour) + end ---- Generate an \\llap'ed box containing the provided string ---- ---- @param str string The string to typeset ---- @return node head The box node -local function llap_string(str) - local first = new_node("glue") - first.width = llap_offset - - local m = first - for letter in str:gmatch(".") do - local n = new_node("glyph") - n.font = SMALL_FONT - n.char = string.byte(letter) - - m.next = n - m = n + return head end - local hss = new_node("glue") - hss.stretch = 1 - hss[stretch_order] = 1 - hss.shrink = 1 - hss[shrink_order] = 1 - m.next = hss - - return node.hpack(first, 0, "exactly") -end - - ---- Typesets the cost of a paragraph beside it in draft mode ---- ---- @param paragraph node ---- @param cost number ---- @return nil -local function show_cost(paragraph, cost) - if not lwc.draft_mode then - return + if context then + nodes.tracers.colors.setlist(head, "lwc_" .. colour) + return head end - local last_hlist_end = last(next_of_type( - last(paragraph), - hlist_id, - { subtype = line_subid, reverse = true } - ).list) + -- Adapted from https://tex.stackexchange.com/a/372437 and + -- https://github.com/zauguin/luametalatex/issues/8. + local start_colour = new_node("whatsit", subtype("pdf_colorstack")) + set_whatsit_field(start_colour, "stack", 0) + set_whatsit_field(start_colour, "command", 1) + set_whatsit_field(start_colour, "data", pdf_colour) - local cost_str - if cost < math.maxinteger then - cost_str = string.format("%.0f", cost) - else - cost_str = "infinite" - end + local end_colour = new_node("whatsit", subtype("pdf_colorstack")) + set_whatsit_field(end_colour, "stack", 0) + set_whatsit_field(end_colour, "command", 2) + + start_colour.next = head + last(head).next = end_colour - last_hlist_end.next = llap_string(cost_str) + return start_colour end @@ -485,10 +538,10 @@ end --- --- Called by the `pre_linebreak_filter` callback --- ---- @param head node ---- @return node +--- @param head node The pre-broken paragraph +--- @return node head The unmodified `head` argument function lwc.save_paragraphs(head) - if (head.id ~= par_id and context) or -- Ensure that we were actually given a par + if (head.id ~= par_id and context) or -- Make sure that `head` is a paragraph status.output_active or -- Don't run during the output routine tex.nest.ptr > 1 -- Don't run inside boxes then @@ -504,10 +557,27 @@ function lwc.save_paragraphs(head) lwc.callbacks.disable_box_warnings.enable() end - long_node, long_info = long_paragraph(head) - natural_info = natural_paragraph(head) + -- Prevent ultra-short last lines (\TeX{}book p. 104). Equivalent to + -- \\parfillskip=0.75\\hsize plus 0.05\\hsize minus 0.75\\hsize. + -- From http://petr.olsak.net/ftp/olsak/tbn/tbn.pdf p. 234 (via Jan Sustek) + long_node, long_info = long_paragraph( + head, + {0.75 * tex.hsize, 0.05 * tex.hsize, 0.75 * tex.hsize, 0, 0} + ) + + if long_info.prevgraf ~= natural_info.prevgraf + 1 then + -- The \\parfillskip settings with \\looseness=1 can sometimes + -- lengthen paragraphs by two lines instead of one. If this happens, + -- we fall back to a slightly-worse \\parfillskip setting. + free_list(long_node) + long_node, long_info = long_paragraph( + head, + {0, 0.8 * tex.hsize, false, 0, false} + ) + end + if renable_box_warnings then lwc.callbacks.disable_box_warnings.disable() end @@ -519,7 +589,13 @@ function lwc.save_paragraphs(head) last(long_node).next = prevdepth end - local long_cost = lwc.paragraph_cost(long_info.demerits, long_info.prevgraf) + local long_cost = lwc.paragraph_cost( + long_info.demerits, + long_info.prevgraf, + natural_info.demerits, + natural_info.prevgraf, + long_node + ) if long_info.prevgraf ~= natural_info.prevgraf + 1 or long_cost < 10 -- Any paragraph that is "free" to expand is suspicious @@ -528,11 +604,12 @@ function lwc.save_paragraphs(head) long_cost = math.maxinteger end + -- The initial glue can disappear in ConTeXt's grid mode, so we + -- save starting at the first hlsit local saved_node = next_of_type(long_node, hlist_id, { subtype = line_subid }) - show_cost(saved_node, long_cost) for n in traverse_id(hlist_id, saved_node) do - n.list = colour_list(n.list, lwc.colours.expanded) + n.list = colour_list(n.list, "expanded") end table.insert(paragraphs, { @@ -542,20 +619,24 @@ function lwc.save_paragraphs(head) free_list(long_node) + costs[#paragraphs + (PAGE_MULTIPLE * pagenum)] = long_cost + -- Print some debugging information - get_chars(head) - debug(get_location(), "nat lines " .. natural_info.prevgraf) - debug( - get_location(), - "nat cost " .. - lwc.paragraph_cost(natural_info.demerits, natural_info.prevgraf) - ) - debug(get_location(), "long lines " .. long_info.prevgraf) - debug( - get_location(), - "long cost " .. - lwc.paragraph_cost(long_info.demerits, long_info.prevgraf) - ) + if lwc.debug then + get_chars(head) + debug(get_location(), "nat lines " .. natural_info.prevgraf) + debug( + get_location(), + "nat cost " .. + lwc.paragraph_cost(natural_info.demerits, natural_info.prevgraf) + ) + debug(get_location(), "long lines " .. long_info.prevgraf) + debug( + get_location(), + "long cost " .. + lwc.paragraph_cost(long_info.demerits, long_info.prevgraf) + ) + end -- \ConTeXt{} crashes if we return `true` return head @@ -572,39 +653,40 @@ end --- @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 = next_of_type(head, hlist_id, { subtype = line_subid }) - local bottom = last(head) + if status.output_active then + -- Don't run during the output routine + return + end - while bottom.id == insert_id do - bottom = bottom.prev - end + -- Get the start and end of the paragraph + local top = next_of_type(head, hlist_id, { subtype = line_subid }) + local bottom = last(head) - if top ~= bottom then - set_attribute( - top, - paragraph_attribute, - #paragraphs + (PAGE_MULTIPLE * pagenum()) - ) - set_attribute( - bottom, - paragraph_attribute, - -1 * (#paragraphs + (PAGE_MULTIPLE * pagenum())) - ) - else - -- We need a special tag for a 1-line paragraph since the node can only - -- have a single attribute value - set_attribute( - top, - paragraph_attribute, - #paragraphs + (PAGE_MULTIPLE * pagenum()) + SINGLE_LINE - ) - end + -- The inserts disappear before `pre_output_routine`, so we shouldn't + -- mark them. + while bottom.id == insert_id do + bottom = bottom.prev + end - if #paragraphs > 0 then - show_cost(head, paragraphs[#paragraphs].cost) - end + if top ~= bottom then + set_attribute( + top, + paragraph_attribute, + #paragraphs + (PAGE_MULTIPLE * pagenum) + ) + set_attribute( + bottom, + paragraph_attribute, + -1 * (#paragraphs + (PAGE_MULTIPLE * pagenum)) + ) + else + -- We need a special tag for a 1-line paragraph since the node can only + -- have a single attribute value + set_attribute( + top, + paragraph_attribute, + #paragraphs + (PAGE_MULTIPLE * pagenum) + SINGLE_LINE + ) end end @@ -625,14 +707,15 @@ local function mark_inserts(head) -- Tag the insert's content so that we can find it later set_attribute(insert.list, insert_attribute, #inserts) + -- We need to tag all lines---not just the start and the end---since + -- \TeX{} can split the insert between pages at any point. for n in traverse(insert.list.next) do set_attribute(n, insert_attribute, -1 * #inserts) end - --[[ 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. - ]] + -- 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 @@ -642,18 +725,18 @@ local function mark_inserts(head) local insert_class if lmtx then - -- FIXME: temporarily hardcode the main "footnote" class - insert_class = 4 -- insert.index + insert_class = insert.index else insert_class = insert.subtype end - --[[ 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. - ]] + -- 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. If this does turn out + -- to be an issue, we can get the insert's class from it's copy + -- at `pre_output_filter` instead of saving it now. set_attribute( hlist_before.list, insert_attribute, @@ -672,8 +755,8 @@ end --- Saves the inserts and tags a typeset paragraph. Called by the --- `post_linebreak_filter` callback. --- ---- @param head node ---- @return node +--- @param head node The head of the broken paragraph +--- @return node head The unmodified `head` parameter function lwc.mark_paragraphs(head) mark_paragraphs(head) mark_inserts(head) @@ -686,7 +769,7 @@ end --- --- @param penalty number --- @return boolean -function is_matching_penalty(penalty) +local function is_matching_penalty(penalty) local widowpenalty = tex.widowpenalty local clubpenalty = tex.clubpenalty local displaywidowpenalty = tex.displaywidowpenalty @@ -694,7 +777,9 @@ function is_matching_penalty(penalty) penalty = penalty - tex.interlinepenalty - -- https://tug.org/TUGboat/tb39-3/tb123mitt-widows-code.pdf#subsection.0.2.1 + -- Adapted from https://tug.org/TUGboat/tb39-3/tb123mitt-widows-code.pdf. + -- This only takes into account the original \TeX{} penalties, not the + -- "new" \eTeX{} \\(club/widow/broken)penalties commands. return penalty ~= 0 and penalty < INFINITY and ( penalty == widowpenalty or @@ -712,8 +797,24 @@ function is_matching_penalty(penalty) end +--- Determines if we should "activate" \lwc/ for the current page/column. +--- +--- Users can redefine this if they wish. +--- +--- @param penalty number The \\outputpenalty for the current page/column +--- @param paragraphs table<table<string, node|number>> The `paragraphs` table +--- @param head node The head of the current page/column +--- @return boolean activate True if \lwc/ should move the last line on this page +function lwc.should_remove_widows(penalty, paragraphs, head) + return is_matching_penalty(penalty) +end + --- Reset any state saved between pages --- +--- This function is *vital* to ensure that we don't leak any nodes. +--- If we do leak nodes, then very large documents will slow down and +--- eventually fail to compile. +--- --- @return nil local function reset_state() for _, paragraph in ipairs(paragraphs) do @@ -725,6 +826,8 @@ local function reset_state() free(insert) end inserts = {} + + pagenum = pagenum + 1 end @@ -732,15 +835,15 @@ end --- --- @return nil local function remove_widows_fail() - warning("Widow/Orphan/broken hyphen NOT removed on page " .. pagenum()) + warning("Widow/Orphan/Broken Hyphen NOT removed on page " .. pagenum) local last_line = next_of_type( - last(tex_lists.page_head), + last(tex_lists[page_head]), hlist_id, { subtype = line_subid, reverse = true } ) if last_line then - last_line.list = colour_list(last_line.list, lwc.colours.failure) + last_line.list = colour_list(last_line.list, "failure") end local next_first_line = next_of_type( @@ -749,7 +852,7 @@ local function remove_widows_fail() { subtype = line_subid } ) if next_first_line then - next_first_line.list = colour_list(next_first_line.list, lwc.colours.failure) + next_first_line.list = colour_list(next_first_line.list, "failure") end reset_state() @@ -780,10 +883,12 @@ local function first_last_paragraphs(head) -- Find the first paragraph on the page, from the top local first_val, first_head = find_attribute(head, paragraph_attribute) - while abs(first_val) // PAGE_MULTIPLE == pagenum() - 1 do - --[[ 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. - ]] + while abs(first_val) // PAGE_MULTIPLE == pagenum - 1 do + -- If the first complete paragraph on the page was initially broken on the + -- previous page, then we can't expand it here. Why can't we expand it? + -- Well, expanding it will nearly always change how the first few lines + -- are printed, but we can't modify those since they've already been + -- shipped out. So, we need to skip these paragraphs. first_val, first_head = find_attribute( first_head.next, paragraph_attribute @@ -826,11 +931,11 @@ local function best_paragraph(head) debug( "selected para", - pagenum() .. "/" .. best_index .. " (" .. best_cost .. ")" + pagenum .. "/" .. best_index .. " (" .. best_cost .. ")" ) if best_cost > tex_count[max_cost] or - best_index == last_paragraph_index or + best_index == last_paragraph_index or -- Shouldn't happen best_cost == math.maxinteger then return nil @@ -858,7 +963,8 @@ local function get_inserts(last_line) -- Demux the insert values local class = line_value // INSERT_CLASS_MULTIPLE - local first_index = (line_value % INSERT_CLASS_MULTIPLE) // INSERT_FIRST_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 @@ -866,23 +972,37 @@ local function get_inserts(last_line) if lmtx then insert_box = tex.getinsertcontent(class) + -- `getinsertcontent` resets the insert box, so we need to + -- re-set it. + tex.setinsertcontent(class, insert_box) else insert_box = tex_box[class] end - -- Get any portions of the insert held over until the next page - local split_insert = next_of_type( - tex_lists[hold_head], - insert_id, - { subtype = class } - ) + -- Get any portions of the insert "held over" until the next page + local split_insert + if lmtx then + split_insert = next_of_type( + tex_lists[hold_head], + insert_id, + { index = class } + ) + else + split_insert = next_of_type( + tex_lists[hold_head], + insert_id, + { subtype = class } + ) + end + -- We use the same procedure for this page and the next page for i, insert in ipairs { insert_box, split_insert } do local m = insert and insert.list while m do -- Iterate through the insert box local box_value box_value, m = find_attribute(m, insert_attribute) + local next = m.next if not m then break @@ -898,13 +1018,15 @@ local function get_inserts(last_line) table.insert(selected_inserts, copy(inserts[box_value])) end - m = free(m) - else - m = m.next + free(m) end + + m = next end end + -- If the box has no contents, then void it so that any \\ifvoid + -- tests work correctly in the output routine. if not insert_box.list then tex_box[class] = nil end @@ -917,7 +1039,11 @@ local function get_inserts(last_line) end if #selected_inserts ~= 0 then - info("Moving footnotes on page " .. pagenum()) + -- This is a somewhat-risky process, so we print an info + -- message just in case something goes wrong. We can probably + -- remove this in the future if we're sure that everything + -- is working correctly. + info("Moving footnotes on page " .. pagenum) end return selected_inserts @@ -932,7 +1058,7 @@ lwc.nobreak_behaviour = "keep" --- some low-level node shuffling. --- --- @param head node The node representing the start of the page ---- @return boolean success +--- @return boolean success Set to false if something went wrong local function move_last_line(head) -- Start of final paragraph debug("remove_widows", "moving last line") @@ -977,19 +1103,19 @@ local function move_last_line(head) potential_penalty.subtype == linebreakpenalty_subid and is_matching_penalty(potential_penalty.penalty) then - warning("Making a new widow/orphan/broken hyphen on page " .. pagenum()) + warning("Making a new widow/orphan/broken hyphen on page " .. pagenum) local second_last_line = next_of_type( potential_penalty, hlist_id, { subtype = line_subid, reverse = true } ) - second_last_line.list = colour_list(second_last_line.list, lwc.colours.failure) + second_last_line.list = colour_list(second_last_line.list, "failure") end last_line = copy_list(n) - last_line.list = colour_list(last_line.list, lwc.colours.moved) + last_line.list = colour_list(last_line.list, "moved") -- Reinsert any inserts originally present in this moved line local selected_inserts = get_inserts(last_line) @@ -1019,13 +1145,29 @@ end --- @param head node --- @param paragraph_index number local function replace_paragraph(head, paragraph_index) - local target_node = copy_list(paragraphs[paragraph_index].node) + -- Remove any inserts. They are completely ignored after + -- the page has been broken, but they can upset LuaMetaLaTeX if + -- they're found somewhere unexpected. + local target_node, last_target_node + for n in traverse(paragraphs[paragraph_index].node) do + -- Just removing the inserts from the list doesn't work properly, + -- so we instead copy over everything that isn't an insert. + if n.id ~= insert_id then + if not target_node then + target_node = copy(n) + last_target_node = target_node + else + last_target_node.next = copy(n) + last_target_node = last_target_node.next + end + end + end local start_found = false local end_found = false local free_nodes_begin - -- Loop through all of the nodes on the page with the lwc attribute + -- Loop through all of the nodes on the page with the \lwc/ attribute local n = head while n do local value @@ -1038,8 +1180,8 @@ local function replace_paragraph(head, paragraph_index) 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 + if value == paragraph_index + (PAGE_MULTIPLE * pagenum) or + value == paragraph_index + (PAGE_MULTIPLE * pagenum) + SINGLE_LINE then debug("remove_widows", "replacement start") start_found = true @@ -1047,7 +1189,9 @@ local function replace_paragraph(head, paragraph_index) -- Fix the `\\baselineskip` glue between paragraphs height_difference = ( next_of_type(n, hlist_id, { subtype = line_subid }).height - - next_of_type(target_node, hlist_id, { subtype = line_subid }).height + next_of_type( + target_node, hlist_id, { subtype = line_subid } + ).height ) local prev_bls = next_of_type( @@ -1065,8 +1209,8 @@ local function replace_paragraph(head, paragraph_index) end -- Insert the end of the replacement paragraph - if value == -1 * (paragraph_index + (PAGE_MULTIPLE * pagenum())) or - value == paragraph_index + (PAGE_MULTIPLE * pagenum()) + SINGLE_LINE + if value == -(paragraph_index + (PAGE_MULTIPLE * pagenum)) or + value == paragraph_index + (PAGE_MULTIPLE * pagenum) + SINGLE_LINE then debug("remove_widows", "replacement end") end_found = true @@ -1095,7 +1239,7 @@ local function replace_paragraph(head, paragraph_index) if start_found and end_found then free_list(free_nodes_begin) else - warning("Paragraph NOT expanded on page " .. pagenum()) + warning("Paragraph NOT expanded on page " .. pagenum) end end @@ -1114,7 +1258,7 @@ 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 + if not lwc.should_remove_widows(tex.outputpenalty, paragraphs, head) then reset_state() return head end @@ -1154,10 +1298,9 @@ function lwc.remove_widows(head) -- 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. - ]] + -- The final \\box255 needs to be exactly \\vsize tall to avoid + -- over/underfull box warnings, so we correct any discrepancies + -- here. local new_vpack = vpack(head) local new_height_diff = new_vpack.height - vsize new_vpack.list = nil @@ -1169,7 +1312,8 @@ function lwc.remove_widows(head) free(bls) if abs(net_height_diff) > 0 and - -- A difference larger than 0.25\\baselineskip is probably not from \lwc/ + -- A difference larger than 0.25\\baselineskip is probably not from + -- \lwc/, so we let those warnings surface abs(net_height_diff) < bls_width / 4 then local bottom_glue = new_node("glue") @@ -1181,7 +1325,7 @@ function lwc.remove_widows(head) "Widow/orphan/broken hyphen successfully removed at paragraph " .. paragraph_index .. " on page " - .. pagenum() + .. pagenum ) reset_state() @@ -1190,6 +1334,143 @@ function lwc.remove_widows(head) end +local show_costs = false +--- Add the paragraph to the list of paragraphs on the page. +--- +--- Called immediately before the page is shipped out so that we can get +--- the costs on the correct side in multi-column layouts. +--- +--- To evenly align all of the costs in the margins, we need to know the +--- the exact position of the start and end of the paragraph on the page. +--- This is surprisingly complicated. +--- +--- @param head node The box to be shipped out +--- @return true +function lwc.show_costs (head) + if not show_costs then + return true + end + + local pagewidth = tex.pagewidth or layouts.getpagedimensions() + + --- Loop over each sublist, add up the total width, and show the costs. + --- + --- @param n node The node to loop over + --- @param width number The accumulated width so far + --- @param parent node The parent node of the current list + --- @return nil + local function recurse(n, width, parent) + for m in traverse(n) do + -- Anything with an \\hbox parent and a width is actual width. + -- (If it had a \\vbox parent, then n.width would actually be height.) + local self_width = 0 + if m.id == glue_id and parent.id == hlist_id then + self_width = effective_glue(m, parent) + elseif m.width and parent.id == hlist_id then + self_width = m.width + end + + -- A node's "shift" attribute is horizontal only if the parent is + -- a \\vbox. This corresponds to the primatives \\moveleft and + -- \\moveright. (If the parent is a \\hbox, then n.shift is + -- vertical and corresponds to \\raise.) + local shift = 0 + if m.shift and + (parent.id == vlist_id or + not is_node(parent)) + then + shift = m.shift + end + + width = width + self_width + + local attr = get_attribute(m, paragraph_attribute) + if attr and abs(attr) % PAGE_MULTIPLE >= SINGLE_LINE then + attr = -1 * (abs(attr) - SINGLE_LINE) + end + + local cost = costs[abs(attr or 0)] + + if attr and attr < 0 and cost and m.list then + -- We've found the end of a marked paragraph! + + -- Generate the \\hbox containing the formatted cost + local cost_str + if not cost then + return + elseif cost < math.maxinteger then + cost_str = str_format("%.0f", cost) + else + cost_str = "infinite" + end + + local prev, first + for letter in cost_str:gmatch(".") do + local curr = new_node("glyph") + curr.font = SMALL_FONT + curr.char = str_byte(letter) + + if not first then + first = curr + else + prev.next = curr + end + prev = curr + end + + local text = hpack(colour_list(first, "cost")) + + -- Make an \\hss to make sure that our `\\hbox`es aren't overfull + local hss = new_node("glue") + hss.stretch = 1 + hss[stretch_order] = 1 + hss.shrink = 1 + hss[shrink_order] = 1 + + local hbox + local offset = new_node("glue") + + if (width >= pagewidth / 2) or + (m.width >= 0.4 * pagewidth) + then -- Right column or single-column + -- Costs in the right margin + offset.width = ( + pagewidth - + width - + m.width - + shift - + tex_dimen[draft_offset] + ) + text.next = hss + hbox = hpack(text, 0, "exactly") + else -- Left column + -- Costs in the left margin + offset.width = ( + tex_dimen[draft_offset] - + m.width - + width - + shift + ) + hss.next = text + hbox = hpack(hss, 0, "exactly") + end + + last(m.list).next = offset + offset.next = hbox + elseif m.list then + recurse(m.list, width - self_width + shift, m) + end + end + end + + -- Start at the root of the page + recurse(head.list, (tex.hoffset or 0) + horigin, {}) + + -- LaTeX requires us to always return true here + return true +end + + --- Create a table of functions to enable or disable a given callback --- --- @param t table Parameters of the callback to create @@ -1198,8 +1479,8 @@ end --- name: string = The name/ID of the callback --- category: string = The category for a \ConTeXt{} "Action" --- position: string = The "position" for a \ConTeXt{} "Action" ---- lowlevel: boolean = If we should use a lowlevel \LuaTeX{} callback instead of a ---- \ConTeXt{} "Action" +--- lowlevel: boolean = If we should use a lowlevel \LuaTeX{} callback +--- instead of a \ConTeXt{} "Action" --- @return table t Enablers/Disablers for the callback --- enable: function = Enable the callback --- disable: function = Disable the callback @@ -1215,9 +1496,8 @@ 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) @@ -1227,18 +1507,18 @@ 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, disable = function() callback.register(t.callback, nil) end, } - elseif optex then -- Op\TeX{} is very similar to luatexbase + elseif optex then -- Op\TeX{} is luckily very similar to luatexbase return { enable = function() callback.add_to_callback(t.callback, t.func, t.name) @@ -1279,6 +1559,13 @@ lwc.callbacks = { category = "finalizers", position = "after", }), + show_costs = register_callback({ + callback = "pre_shipout_filter", + func = lwc.show_costs, + name = "show_costs", + category = "shipouts", + position = "finishers", + }), } @@ -1303,11 +1590,10 @@ function lwc.disable_callbacks() 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 *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. lwc_enabled = false else info("Already disabled") @@ -1358,6 +1644,8 @@ local function register_tex_cmd(name, func, args) scanners[#scanners+1] = token['scan_' .. arg] end + -- An intermediate function that properly "scans" for its arguments + -- in the \TeX{} side. scanning_func = function() local values = {} for _, scanner in ipairs(scanners) do @@ -1385,7 +1673,8 @@ local function register_tex_cmd(name, func, args) end end - +--[[ Make all of the \lwc/ Lua commands available from \TeX{} + ]] register_tex_cmd("if_enabled", lwc.if_lwc_enabled, {}) register_tex_cmd("enable", lwc.enable_callbacks, {}) register_tex_cmd("disable", lwc.disable_callbacks, {}) @@ -1404,12 +1693,26 @@ register_tex_cmd( { "string" } ) register_tex_cmd( - "draft", + "show_costs", + function(str) + show_costs = str ~= "0" and str ~= "false" and str ~= "stop" + end, + { "string" } +) +register_tex_cmd( + "show_colours", function(str) - lwc.draft_mode = str ~= "0" and str ~= "false" and str ~= "stop" + show_colours = str ~= "0" and str ~= "false" and str ~= "stop" end, { "string" } ) +register_tex_cmd( + "pre_shipout", + function(box) + luatexbase.call_callback('pre_shipout_filter', tex_box[box]) + end, + { "int" } +) --- Silence the luatexbase "Enabling/Removing <callback>" info messages --- @@ -1424,8 +1727,8 @@ register_tex_cmd( --- with useless error messages, so we disable it here. --- --- 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. +--- `add_to_callback` function. This is almost certainly a terrible idea, but I +--- don't know of a better way to do it. --- --- @return nil local function silence_luatexbase() @@ -1451,17 +1754,28 @@ local function silence_luatexbase() end ---[[ 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. - ]] +-- 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 +-- Register colours for ConTeXt +if context then + for colour, values in pairs(lwc.colours) do + attributes.colors.defineprocesscolor( + "lwc_" .. colour, + str_format("r=%.2f, g=%.2f, b=%.2f", table.unpack(values)) + ) + end +end + -- Activate \lwc/ lwc.callbacks.remove_widows.enable() +lwc.callbacks.show_costs.enable() return lwc + |