summaryrefslogtreecommitdiff
path: root/macros/luatex/generic/lua-widow-control/lua-widow-control.lua
diff options
context:
space:
mode:
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.lua390
1 files changed, 290 insertions, 100 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 f1ecb54c65..5801feef12 100644
--- a/macros/luatex/generic/lua-widow-control/lua-widow-control.lua
+++ b/macros/luatex/generic/lua-widow-control/lua-widow-control.lua
@@ -5,8 +5,24 @@
SPDX-FileCopyrightText: 2022 Max Chernoff
]]
-lwc = {}
+lwc = lwc or {}
lwc.name = "lua-widow-control"
+lwc.nobreak_behaviour = "keep"
+
+local write_nl = texio.write_nl
+local string_rep = string.rep
+local function debug_print(title, text)
+ if not lwc.debug then return end
+
+ local filler = 15 - #title
+
+ if text then
+ write_nl("log", "LWC (" .. title .. string_rep(" ", filler) .. "): " .. text)
+ else
+ write_nl("log", "LWC: " .. string_rep(" ", 18) .. title)
+ end
+end
+
--[[
\lwc/ is intended to be format-agonistic. It only runs on Lua\TeX{},
@@ -37,70 +53,90 @@ local last = node.slide
local copy = node.copy_list
local par_id = node.id("par") or node.id("local_par")
local glue_id = node.id("glue")
-local set_attribute = node.set_attribute
-local has_attribute = node.has_attribute
+local glyph_id = node.id("glyph")
+local penalty_id = node.id("penalty")
+local hlist_id = node.id("hlist")
+local traverse = node.traverse
+local set_attribute = node.set_attribute or node.setattribute
+local find_attribute = node.find_attribute or node.findattribute
local flush_list = node.flush_list or node.flushlist
local free = node.free
-local min_col_width = tex.sp("25em")
-local maxdimen = 1073741823 -- \\maxdimen in sp
+local min_col_width = tex.sp("250pt")
--[[
Package/module initialization
]]
-local warning, info, attribute, contrib_head, stretch_order, pagenum
-
-if context and lmtx then
- warning = logs.reporter("module", lwc.name)
- info = logs.reporter("module", lwc.name)
- attribute = attributes.public(lwc.name)
- contrib_head = 'contributehead' -- For \LuaMetaTeX{}
+local warning,
+ info,
+ attribute,
+ contrib_head,
+ stretch_order,
+ pagenum,
+ emergencystretch,
+ max_cost
+
+if lmtx then
+ debug_print("LMTX")
+ contrib_head = 'contributehead'
stretch_order = "stretchorder"
- pagenum = function() return tex.count["realpageno"] end
-elseif context and not lmtx then
+else
+ contrib_head = 'contrib_head'
+ stretch_order = "stretch_order"
+end
+
+if context then
+ debug_print("ConTeXt")
warning = logs.reporter("module", lwc.name)
info = logs.reporter("module", lwc.name)
attribute = attributes.public(lwc.name)
- contrib_head = 'contrib_head'
- stretch_order = "stretch_order"
pagenum = function() return tex.count["realpageno"] end
-elseif plain or latex then
- luatexbase.provides_module {
- name = lwc.name,
- date = "2022/02/22", --%%date
- version = "1.1.6", --%%version
- description = [[
-
- This module provides a LuaTeX-based solution to prevent
- widows and orphans from appearing in a document. It does
- so by increasing or decreasing the lengths of previous
- 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)
- contrib_head = 'contrib_head' -- For \LuaTeX{}
- stretch_order = "stretch_order"
- pagenum = function() return tex.count[0] end
-elseif optex then
- local write_nl = texio.write_nl
- 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)
- contrib_head = 'contrib_head'
- stretch_order = "stretch_order"
+ emergencystretch = "lwc_emergency_stretch"
+ max_cost = "lwc_max_cost"
+elseif plain or latex or optex then
pagenum = function() return tex.count[0] end
+
+ if tex.isdimen("g__lwc_emergencystretch_dim") then
+ emergencystretch = "g__lwc_emergencystretch_dim"
+ max_cost = "g__lwc_maxcost_int"
+ else
+ emergencystretch = "lwcemergencystretch"
+ max_cost = "lwcmaxcost"
+ end
+
+ if plain or latex then
+ debug_print("Plain/LaTeX")
+ luatexbase.provides_module {
+ name = lwc.name,
+ date = "2022/03/07", --%%slashdate
+ version = "2.0.0", --%%version
+ description = [[
+
+This module provides a LuaTeX-based solution to prevent
+widows and orphans from appearing in a document. It does
+so by increasing or decreasing the lengths of previous
+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)
+ elseif optex then
+ debug_print("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)
+ end
else -- uh oh
- error [[
- Unsupported format.
+ error [[Unsupported format.
- Please use (Lua)LaTeX, Plain (Lua)TeX, ConTeXt (MKXL/LMTX),
- or OpTeX.
- ]]
+Please use LaTeX, Plain TeX, ConTeXt or OpTeX.]]
end
local paragraphs = {} -- List to hold the alternate paragraph versions
+local function get_location()
+ return "At " .. pagenum() .. "/" .. #paragraphs
+end
+
--[[
Function definitions
@@ -165,11 +201,39 @@ local function register_callback(t)
end
end
+local function get_chars(head)
+ if not lwc.debug then return end
+
+ local chars = ""
+ for n in traverse(head) do
+ if n.id == glyph_id then
+ if n.char < 127 then
+ chars = chars .. string.char(n.char)
+ else
+ chars = chars .. "#"
+ end
+ elseif n.id == glue_id then
+ chars = chars .. " "
+ end
+ if #chars > 25 then
+ break
+ end
+ end
+
+ debug_print(get_location(), chars)
+end
+
+
+function lwc.paragraph_cost(demerits, lines)
+ return demerits / (2 * math.sqrt(lines))
+end
+
+
--- Saves each paragraph, but lengthened by 1 line
function lwc.save_paragraphs(head)
-- Prevent the "underfull hbox" warnings when we store a potential paragraph
local renable_box_warnings
- if (latex or plain) and
+ if (context or optex) or
#luatexbase.callback_descriptions("hpack_quality") == 0
then -- See #18 and michal-h21/linebreaker#3
renable_box_warnings = true
@@ -194,7 +258,7 @@ function lwc.save_paragraphs(head)
-- Break the paragraph 1 line longer than natural
local long_node, long_info = tex.linebreak(new_head, {
looseness = 1,
- emergencystretch = tex.dimen.lwcemergencystretch,
+ emergencystretch = tex.getdimen(emergencystretch),
})
-- Break the natural paragraph so we know how long it was
@@ -205,20 +269,31 @@ function lwc.save_paragraphs(head)
lwc.callbacks.disable_box_warnings.disable()
end
- -- If we can't lengthen the paragraph, assign a \emph{very} large demerit value
- local long_demerits
- if long_info.prevgraf == natural_info.prevgraf then
- long_demerits = maxdimen
- else
- long_demerits = long_info.demerits
- end
-
-- Offset the accumulated \\prevdepth
local prevdepth = node.new("glue")
prevdepth.width = natural_info.prevdepth - long_info.prevdepth
last(long_node).next = prevdepth
- table.insert(paragraphs, { demerits = long_demerits, node = long_node })
+ if long_info.prevgraf == natural_info.prevgraf + 1 then
+ table.insert(paragraphs, {
+ cost = lwc.paragraph_cost(long_info.demerits, long_info.prevgraf),
+ node = long_node
+ })
+ end
+
+ get_chars(head)
+ debug_print(get_location(), "nat lines " .. natural_info.prevgraf)
+ debug_print(
+ get_location(),
+ "nat cost " ..
+ lwc.paragraph_cost(natural_info.demerits, natural_info.prevgraf)
+ )
+ debug_print(get_location(), "long lines " .. long_info.prevgraf)
+ debug_print(
+ get_location(),
+ "long cost " ..
+ lwc.paragraph_cost(long_info.demerits, long_info.prevgraf)
+ )
-- \LuaMetaTeX{} crashes if we return `true`
return head
@@ -251,11 +326,12 @@ local function safe_last(head)
if ids[id] then
warning [[Circular node list detected!
-This should never happen. I'll try and recover, but your output may be
-corrupted. As a workaround, disable lua-widow-control for the
-affected paragraph or change the page breaks in your document.]]
-
+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))
+
return prev
end
@@ -276,12 +352,16 @@ end
--- 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.
function lwc.remove_widows(head)
+ local head_save = head -- Save the start of the `head` linked-list
+
local penalty = tex.outputpenalty - tex.interlinepenalty
local widowpenalty = tex.widowpenalty
local clubpenalty = tex.clubpenalty
local displaywidowpenalty = tex.displaywidowpenalty
local brokenpenalty = tex.brokenpenalty
+ debug_print("outputpenalty", tex.outputpenalty .. " " .. #paragraphs)
+
--[[
We only need to process pages that have orphans or widows. If `paragraphs`
is empty, then there is nothing that we can do.
@@ -289,7 +369,9 @@ function lwc.remove_widows(head)
The list of penalties is from:
https://tug.org/TUGboat/tb39-3/tb123mitt-widows-code.pdf#subsection.0.2.1
]]
- if (penalty == widowpenalty or
+ if penalty ~= 0 and
+ penalty < 10000 and
+ (penalty == widowpenalty or
penalty == displaywidowpenalty or
penalty == clubpenalty or
penalty == clubpenalty + widowpenalty or
@@ -302,28 +384,26 @@ function lwc.remove_widows(head)
penalty == brokenpenalty + clubpenalty + displaywidowpenalty) and
#paragraphs >= 1 then
else
+ paragraphs = {}
return head
end
info("Widow/orphan detected. Attempting to remove.")
- local head_save = head -- Save the start of the `head` linked-list
-
--[[
- Find the paragraph on the page with the minimum penalty.
-
- This would be a 1-liner in Python or JavaScript, but Lua is pretty low-level,
- so there's quite a bit of code here.
+ Find the paragraph on the page with the least cost.
]]
local paragraph_index = 1
- local minimum_demerits = paragraphs[paragraph_index].demerits
+ local best_cost = paragraphs[paragraph_index].cost
-- We find the current "best" replacement, then free the unused ones
for i, paragraph in pairs(paragraphs) do
- if paragraph.demerits < minimum_demerits and i <= #paragraphs - 1 then
+ if paragraph.cost < best_cost and i <= #paragraphs - 1 then
+ -- Clear the old best paragraph
flush_list(paragraphs[paragraph_index].node)
paragraphs[paragraph_index].node = nil
- paragraph_index, minimum_demerits = i, paragraph.demerits
+ -- Set the new best paragraph
+ paragraph_index, best_cost = i, paragraph.cost
elseif i > 1 then
-- Not sure why `i > 1` is required?
flush_list(paragraph.node)
@@ -331,50 +411,113 @@ function lwc.remove_widows(head)
end
end
+ debug_print(
+ "selected para",
+ pagenum() ..
+ "/" ..
+ paragraph_index ..
+ " (" ..
+ best_cost ..
+ ")"
+ )
+
+ if best_cost > tex.getcount(max_cost) then
+ -- If the best replacement is too bad, we can't do anything
+ warning("Widow/Orphan NOT removed on page " .. pagenum())
+ paragraphs = {}
+ return head_save
+ end
+
local target_node = paragraphs[paragraph_index].node
- local clear_flag = false
- -- Loop through all of the nodes on the page
+ -- Start of final paragraph
+ debug_print("remove_widows", "moving last line")
+
+ head = last(head_save).prev
+ local big_penalty_found, last_line, hlist_head
+ while head do
+ if head.id == glue_id then
+ -- Ignore any glue nodes
+ elseif head.id == penalty_id and head.penalty >= 10000 then
+ -- Infinite break penalty
+ big_penalty_found = true
+ elseif big_penalty_found and head.id == hlist_id then
+ -- Line before the penalty
+ if lwc.nobreak_behaviour == "keep" then
+ hlist_head = head
+ big_penalty_found = false
+ elseif lwc.nobreak_behaviour == "split" then
+ head = last(head_save)
+ break
+ elseif lwc.nobreak_behaviour == "warn" then
+ warning("Widow/Orphan NOT removed on page " .. pagenum())
+ paragraphs = {}
+ return head_save
+ end
+ else
+ -- Not found
+ if hlist_head then
+ head = hlist_head
+ else
+ head = last(head_save)
+ end
+ break
+ end
+ head = head.prev
+ end
+
+ last_line = copy(head)
+ last(last_line).next = copy(tex.lists[contrib_head])
+
+ head.prev.prev.next = nil
+ -- Move the last line to the next page
+ tex.lists[contrib_head] = last_line
+
+ local free_next_nodes = false
+
+ -- Loop through all of the nodes on the page with the lwc attribute
+ head = head_save
while head do
+ local value
+ value, head = find_attribute(head, attribute)
+
+ if not head then
+ break
+ end
+
+ debug_print("remove_widows", "found " .. value)
+
-- Insert the start of the replacement paragraph
- if has_attribute(head, attribute, paragraph_index) then
+ if value == paragraph_index then
+ debug_print("remove_widows", "replacement start")
safe_last(target_node) -- Remove any loops
head.prev.next = target_node
- clear_flag = true
+ free_next_nodes = true
end
-- Insert the end of the replacement paragraph
- if has_attribute(head, attribute, -1 * paragraph_index) then
+ if value == -1 * paragraph_index then
+ debug_print("remove_widows", "replacement end")
safe_last(target_node).next = head.next
- clear_flag = false
+ break
end
- -- Start of final paragraph
- if has_attribute(head, attribute, #paragraphs) then
- local last_line = copy(last(head))
-
- last(last_line).next = copy(tex.lists[contrib_head])
-
- last(head).prev.prev.next = nil
- -- Move the last line to the next page
- tex.lists[contrib_head] = last_line
- info(
- "Widow/orphan successfully removed at paragraph "
- .. paragraph_index
- .. " on page "
- .. pagenum()
- .. "."
- )
- end
-
- if clear_flag then
+ if free_next_nodes then
head = free(head)
else
head = head.next
end
end
+ info(
+ "Widow/orphan successfully removed at paragraph "
+ .. paragraph_index
+ .. " on page "
+ .. pagenum()
+ .. "."
+ )
+
paragraphs = {} -- Clear paragraphs array at the end of the page
return head_save
@@ -413,18 +556,19 @@ lwc.callbacks = {
local enabled = false
function lwc.enable_callbacks()
+ debug_print("callbacks", "enabling")
if not enabled then
- lwc.callbacks.remove_widows.enable()
lwc.callbacks.save_paragraphs.enable()
lwc.callbacks.mark_paragraphs.enable()
enabled = true
else
- warning("Already enabled")
+ info("Already enabled")
end
end
function lwc.disable_callbacks()
+ debug_print("callbacks", "disabling")
if enabled then
lwc.callbacks.save_paragraphs.disable()
lwc.callbacks.mark_paragraphs.disable()
@@ -436,11 +580,12 @@ function lwc.disable_callbacks()
enabled = false
else
- warning("Already disabled")
+ info("Already disabled")
end
end
function lwc.if_lwc_enabled()
+ debug_print("iflwc")
if enabled then
tex.sprint("\\iftrue")
else
@@ -448,4 +593,49 @@ function lwc.if_lwc_enabled()
end
end
+--- Silence the luatexbase "Enabling/Removing <callback>" info messages
+---
+--- Every time that a paragraph is typeset, \lwc/ hooks in
+--- and typesets the paragraph 1 line longer. Some of these longer paragraphs
+--- will have pretty bad badness values, so TeX will issue an over/underfull
+--- hbox warning. To block these warnings, we hook into the `hpack_quality`
+--- callback and disable it so that no warning is generated.
+---
+--- However, each time that we enable/disable the null `hpack_quality` callback,
+--- luatexbase puts an info message in the log. This completely fills the log file
+--- 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.
+local function silence_luatexbase()
+ local nups = debug.getinfo(luatexbase.add_to_callback).nups
+
+ for x = 1, nups do
+ local name, func = debug.getupvalue(luatexbase.add_to_callback, x)
+ if name == "luatexbase_log" then
+ debug.setupvalue(
+ luatexbase.add_to_callback,
+ x,
+ function(text)
+ if text:match("^Inserting") or text:match("^Removing") then
+ return
+ else
+ func(text)
+ end
+ end
+ )
+ return
+ end
+ end
+end
+
+
+-- Activate \lwc/
+if plain or latex then
+ silence_luatexbase()
+end
+
+lwc.callbacks.remove_widows.enable()
+
return lwc