-- cloze.lua
-- Copyright 2015-2025 Josef Friedrich
--
-- This work may be distributed and/or modified under the
-- conditions of the LaTeX Project Public License, either version 1.3c
-- of this license or (at your option) any later version.
-- The latest version of this license is in
-- http://www.latex-project.org/lppl.txt
-- and version 1.3c or later is part of all distributions of LaTeX
-- version 2008/05/04 or later.
--
-- This work has the LPPL maintenance status `maintained'.
--
-- The Current Maintainer of this work is Josef Friedrich.
--
-- This work consists of the files cloze.lua, cloze.tex,
-- and cloze.sty.
---
---
Naming conventions
---
---* _Variable_ names for _nodes_ are suffixed with `_node`, for example
--- `head_node`.
---* _Variable_ names for _lengths_ (dimensions) are suffixed with
--- `_length`, for example `width`.
---
---__Initialisation of the function tables__
---It is good form to provide some background informations about this Lua
---module.
if not modules then
modules = {}
end
modules['cloze'] = {
version = '1.8.1',
comment = 'cloze',
author = 'Josef Friedrich, R.-M. Huber',
copyright = 'Josef Friedrich, R.-M. Huber',
license = 'The LaTeX Project Public License Version 1.3c 2008-05-04',
}
local farbe = require('farbe')
local luakeys = require('luakeys')()
local lparse = require('lparse')
local ansi_color = luakeys.utils.ansi_color
local log = luakeys.utils.log
local tex_printf = luakeys.utils.tex_printf
---Option handling.
---
---The table `config` bundles functions that deal with the option
---handling.
---
---Marker processing (marker)
---
---A marker is a whatsit node of the subtype `user_defined`. A marker
---has two purposes:
---
---* Mark the begin and the end of a gap.
---* Store a index number, that points to a Lua table, which holds some
--- additional data like the local options.
---
---@alias MarkerMode 'basic'|'strike'|'fix'|'par' # The argument `mode` accepts the string values `basic`, `fix` and `par`.
---
---@alias MarkerPosition 'start'|'stop' # The argument `position` is either set to `start` or to `stop`.
---
---@class MarkerData
---@field mode MarkerMode
---@field position MarkerPosition
---@field local_opts Options
---
---All values and functions, which are related to the option
---management, are stored in a table called `config`.
local config = (function()
---
---I didn’t know what value I should take as `user_id`. Therefore I
---took my birthday and transformed it into a large number.
local user_id = 3121978
---
---Store all local options of the markers.
---@type {[integer]: MarkerData }
local storage = {}
---@class Options
---@field align? 'l'|'r'
---@field box_height? string
---@field box_rule? string
---@field box_width? string
---@field distance? string
---@field extension_count? integer
---@field extension_height? string
---@field extension_width? string
---@field line_color? string
---@field log? number
---@field margin? string
---@field min_lines? integer
---@field spacing? number
---@field spread? number
---@field text_color? string
---@field thickness? string
---@field visibility? boolean
---@field width? string
---The default options.
local defaults = {
align = 'l',
box_height = false,
box_rule = '0.4pt',
box_width = '\\linewidth',
distance = '1.5pt',
extension_count = 5,
extension_height = '2ex',
extension_width = '1em',
line_color = 'black',
log = 0,
margin = '3pt',
min_lines = 0,
spacing = '1.6',
spread = 0,
text_color = 'blue',
thickness = '0.4pt',
visibility = true,
width = '2cm',
}
---
---The global options set by the user.
---@type Options
local global_options = {}
---
---The local options.
---@type Options
local local_options = {}
local index
---
---`index` is a counter. The functions `get_index()`
---increases the counter by one and then returns it.
---
---@return integer # The index number of the corresponding table in `storage`.
local function get_index()
if not index then
index = 0
end
index = index + 1
return index
end
---
---The function `get_storage()` retrieves values which belong
--- to a whatsit marker.
---
---@param index integer # The argument `index` is a numeric value.
---
---@return MarkerData value
local function get_storage(index)
return storage[index]
end
---
---`set_storage()` stores the local options in the Lua table
--- `storage`.
---
---It returns a numeric index number. This index number is the key,
---where the local options in the Lua table are stored.
---
---@param mode MarkerMode
---@param position MarkerPosition
---
---@return number # The index number of the corresponding table in
--- `storage`.
local function set_storage(mode, position)
local index = get_index()
local data = { mode = mode, position = position }
if position == 'start' then
data.local_opts = {}
for key, value in pairs(local_options) do
data.local_opts[key] = value
end
end
storage[index] = data
return index
end
---
---We create a user defined whatsit node that can store a number (type
--- = 100).
---
---In order to distinguish this node from other user defined whatsit
---nodes we set the `user_id` to a large number. We call this whatsit
---node a marker.
---
---@param index number The argument `index` is a number, which is associated to values in the `storage` table.
---
---@return UserDefinedWhatsitNode
local function create_marker(index)
local marker = node.new('whatsit', 'user_defined') --[[@as UserDefinedWhatsitNode]]
marker.type = 100 -- number
marker.user_id = user_id
marker.value = index
return marker
end
---
---Write a marker node to TeX's current node list.
---
---@param mode MarkerMode
---@param position MarkerPosition
local function write_marker(mode, position)
local index = set_storage(mode, position)
local marker = create_marker(index)
node.write(marker)
end
---
---Check if the given node is a marker.
---
---@param item Node
---
---@return boolean
local function is_marker(item)
local n = item --[[@as UserDefinedWhatsitNode]]
if n.id == node.id('whatsit') and n.subtype ==
node.subtype('user_defined') and n.user_id == user_id then
return true
end
return false
end
---
---Test whether the node `n` is a marker and retrieve the
---the corresponding marker data.
---
---@param n UserDefinedWhatsitNode # The argument `n` is a node of unspecified type.
---
---@return MarkerData|nil # The marker data or nothing if given node is not a marker.
local function get_marker_data(n)
if n.id == node.id('whatsit') and n.subtype ==
node.subtype('user_defined') and n.user_id == user_id then
local data = get_storage(n.value --[[@as integer]] )
if data.position == 'start' then
if data.local_opts == nil then
local_options = {}
else
local_options = data.local_opts
end
end
return data
end
end
---
---This functions tests, whether the given node `item` is a marker.
---
---@param head_node Node # The current node.
---@param mode MarkerMode
---@param position MarkerPosition
---
---@return boolean
local function check_marker(head_node, mode, position)
local data =
get_marker_data(head_node --[[@as UserDefinedWhatsitNode]] )
if data and data.mode == mode and data.position == position then
return true
end
return false
end
---
---`get_marker` returns the given marker.
---
---@param head_node Node # The current node.
---@param mode MarkerMode
---@param position MarkerPosition
---
---@return false|Node # The node if `head_node` is a marker node.
local function get_marker(head_node, mode, position)
local out
if check_marker(head_node, mode, position) then
out = head_node
else
out = false
end
if out and position == 'start' then
get_marker_data(head_node --[[@as UserDefinedWhatsitNode]] )
end
return out
end
---
---Remove a whatsit marker.
---
---It only deletes a node, if a marker is given.
---
---@param marker Node
---
---@return Node|nil head
---@return Node|nil current
local function remove_marker(marker)
if is_marker(marker) then
return node.remove(marker, marker)
end
end
---@type 'local'|'global'
local options_dest
---
---Store a value `value` and his associated key `key`
---either to the global (`global_options`) or to the local
---(`local_options`) option table.
---
---The global boolean variable `local_options` controls in
---which table the values are stored.
---
---@param key string # The option key.
---@param value any # The value that is stored under the options key.
local function set_option(key, value)
if value == '' then
return false
end
log.info('Set %s option “%s” to “%s”', options_dest, key,
value)
if options_dest == 'global' then
global_options[key] = value
else
local_options[key] = value
end
end
---
---Set the variable `options_dest`.
---
---@param dest 'local'|'global'
local function set_options_dest(dest)
options_dest = dest
end
---
---Clear the local options storage.
local function unset_local_options()
local_options = {}
end
---
---Clear the global options storage.
local function unset_global_options()
global_options = {}
end
---
---Test whether the value `value` is not empty and has a
---value.
---
---@param value any # A value of different types.
---
---@return boolean # True is the value is set otherwise false.
local function has_value(value)
if value == nil or value == '' then
return false
else
return true
end
end
---
---Retrieve a value from a given key. First search for the value in the
---local options, then in the global options. If both option storages are
---empty, the default value will be returned.
---
---@param key string # The name of the options key.
---
---@return any # The value of the corresponding option key.
local function get(key)
local value_local = local_options[key]
local value_global = global_options[key]
local value, source
if has_value(value_local) then
source = 'local'
value = local_options[key]
elseif has_value(value_global) then
source = 'global'
value = value_global
else
source = 'default'
value = defaults[key]
end
local g = ansi_color.green
log.debug(
'Get value “%s” from the key “%s” the %s options storage',
g(value), g(key), g(source))
return value
end
---
---Return the default value of the given option.
---
---@param key any # The name of the options key.
---
---@return any # The corresponding value of the options key.
local function get_defaults(key)
return defaults[key]
end
local defs = {
align = {
description = 'Align the text of a fixed size cloze.',
process = function(value)
set_option('align', value)
end,
},
box_height = {
description = 'The height of a cloze box.',
alias = { 'boxheight', 'box_height' },
process = function(value)
set_option('box_height', value)
end,
},
box_rule = {
description = 'The thickness of the rule around a cloze box.',
alias = { 'boxrule', 'box_rule' },
process = function(value)
set_option('box_rule', value)
end,
},
box_width = {
description = 'The width of a cloze box.',
alias = { 'boxwidth', 'box_width' },
process = function(value)
set_option('box_width', value)
end,
},
debug = {
data_type = 'integer',
process = function(value)
log.set(value)
end,
},
distance = {
description = 'The distance between the cloze text and the cloze line.',
process = function(value)
set_option('distance', value)
end,
},
extension_count = {
description = 'The number of extension units.',
alias = 'extensioncount',
process = function(value)
set_option('extension_count', value)
end,
},
extension_height = {
description = 'The height of one extension unit (default: 2ex).',
alias = 'extensionheight',
process = function(value)
set_option('extension_height', value)
end,
},
extension_width = {
description = 'The width of one extension unit (default: 1em).',
alias = 'extensionwidth',
process = function(value)
set_option('extension_width', value)
end,
},
line_color = {
description = 'A color name to colorize the cloze line.',
alias = 'linecolor',
process = function(value, input)
tex_printf('\\FarbeImport{%s}', value)
set_option('line_color', value)
end,
},
log = {
description = 'Set the log level.',
data_type = 'integer',
process = function(value, input)
log.set(value)
end,
},
margin = {
description = 'Indicates how far the cloze line sticks up horizontally from the text.',
process = function(value)
set_option('margin', value)
end,
},
min_lines = {
alias = { 'minimum_lines', 'minlines' },
description = 'How many lines a clozepar at least must have.',
process = function(value)
set_option('min_lines', value)
end,
},
spacing = {
description = 'The spacing between lines (environment clozespace).',
process = function(value)
set_option('spacing', value)
end,
},
spread = {
description = 'Enlarge or spread a gap by a certain factor.',
process = function(value)
set_option('spread', value)
end,
},
text_color = {
description = 'The color (name) of the cloze text.',
alias = 'textcolor',
data_type = 'string',
process = function(value)
tex_printf('\\FarbeImport{%s}', value)
set_option('text_color', value)
end,
},
thickness = {
description = 'The thickness of the cloze line.',
process = function(value)
set_option('thickness', value)
end,
},
visibility = {
description = 'Show or hide the cloze text.',
opposite_keys = { [true] = 'show', [false] = 'hide' },
process = function(value)
set_option('visibility', value)
end,
},
width = {
description = 'The width of the cloze line of the command \\clozefix.',
process = function(value)
set_option('width', value)
end,
},
}
---
---@param kv_string string
---@param options_dest 'local'|'global'
local function parse_options(kv_string, options_dest)
unset_local_options()
set_options_dest(options_dest)
luakeys.parse(kv_string, { defs = defs, debug = log.get() > 3 })
end
local defs_manager = luakeys.DefinitionManager(defs)
return {
get = get,
get_defaults = get_defaults,
unset_global_options = unset_global_options,
unset_local_options = unset_local_options,
set_options_dest = set_options_dest,
remove_marker = remove_marker,
check_marker = check_marker,
set_option = set_option,
write_marker = write_marker,
get_marker = get_marker,
get_marker_data = get_marker_data,
parse_options = parse_options,
defs_manager = defs_manager,
}
end)()
local utils = (function()
---
---Create a new PDF colorstack whatsit node.
---
---`utils.create_color()` is a wrapper for the function
---`utils.create_colorstack()`. It queries the current values of the
---options `line_color` and `text_color`.
---
---@param kind 'line'|'text'
---@param command 'push'|'pop'
---
---@return PdfColorstackWhatsitNode
local function create_color(kind, command)
local color_spec
if kind == 'line' then
color_spec = config.get('line_color')
else
color_spec = config.get('text_color')
end
local color = farbe.Color(color_spec)
return color:create_pdf_colorstack_node(command)
end
---
---Create a rule node that is used as a line for the cloze texts.
---
---The `depth` and the `height` of the rule are calculated form the options
---`thickness` and `distance`.
---
---@param width number # The argument `width` must have the length unit __scaled points__.
---
---@return RuleNode
local function create_line(width)
local rule = node.new('rule') --[[@as RuleNode]]
local thickness = tex.sp(config.get('thickness'))
local distance = tex.sp(config.get('distance'))
rule.depth = distance + thickness
rule.height = -distance
rule.width = width
return rule
end
---
---Insert a `list` of nodes after or before the `current` node.
---
---The `head_node` argument is optional. Unfortunately, it is necessary in some edge cases.
---If `head_node` is omitted, the `current` node is used.
---
---@param position 'before'|'after' # The argument `position` can take the values `'after'` or `'before'`.
---@param current Node
---@param list table
---@param head_node? Node
---
---@return Node
local function insert_list(position, current, list, head_node)
if not head_node then
head_node = current
end
for _, insert in ipairs(list) do
if position == 'after' then
head_node, current = node.insert_after(head_node, current,
insert)
elseif position == 'before' then
head_node, current = node.insert_before(head_node, current,
insert)
end
end
return current
end
---
---Enclose a rule node (cloze line) with two PDF colorstack whatsits.
---
---The first colorstack node colors the line, the second resets the
---color.
---
---__Node list__: `whatsit:pdf_colorstack` (line_color) - `rule` (width) - `whatsit:pdf_colorstack` (reset_color)
---
---@param current Node
---@param width number
---
---@return Node
local function insert_line(current, width)
return insert_list('after', current, {
create_color('line', 'push'),
create_line(width),
create_color('line', 'pop'),
})
end
---
---Encloze a rule node with color nodes as the function
-- `utils.insert_line` does.
---
---In contrast to -`utils.insert_line` the three nodes are appended to
---TeX’s ‘current-list’. They are not inserted in a node list, which
---is accessed by a Lua callback.
---
---__Node list__: `whatsit:pdf_colorstack` (line_color) - `rule` (width) - `whatsit:pdf_colorstack` (reset_color)
---
local function write_line_nodes()
node.write(create_color('line', 'push'))
node.write(create_line(tex.sp(config.get('width'))))
node.write(create_color('line', 'pop'))
end
---
---Create a line which stretches indefinitely in the
---horizontal direction.
---
---@return GlueNode
local function create_linefil()
local glue = node.new('glue') --[[@as GlueNode]]
glue.subtype = 100
glue.stretch = 65536
glue.stretch_order = 3
local rule = create_line(0)
rule.dir = 'TLT'
glue.leader = rule
return glue
end
---
---Surround a indefinitely strechable line with color whatsits and puts
---it to TeX’s ‘current (node) list’ (write).
local function write_linefil_nodes()
node.write(create_color('line', 'push'))
node.write(create_linefil())
node.write(create_color('line', 'pop'))
end
---
---Create a kern node with a given width.
---
---@param width number # The argument `width` had to be specified in scaled points.
---
---@return KernNode
local function create_kern_node(width)
local kern_node = node.new('kern') --[[@as KernNode]]
kern_node.kern = width
return kern_node
end
---
---Add at the beginning of each `hlist` node list a strut (a invisible
---character).
---
---Now we can add line, color etc. nodes after the first node of a hlist
---not before - after is much more easier.
---
---@param hlist_node HlistNode
---
---@return HlistNode hlist_node
---@return Node strut_node
---@return Node prev_head_node
local function insert_strut_into_hlist(hlist_node)
local prev_head_node = hlist_node.head
local kern_node = create_kern_node(0)
local strut_node = node.insert_before(hlist_node.head,
prev_head_node, kern_node)
hlist_node.head = prev_head_node.prev
return hlist_node, strut_node, prev_head_node
end
---
---Write a kern node to the current node list. This kern node can be
---used to build a margin.
local function write_margin_node()
node.write(create_kern_node(tex.sp(config.get('margin'))))
end
---
---Search for a `hlist` (subtype `line`) and insert a strut node into
---the list if a hlist is found.
---
---@param head_node Node # The head of a node list.
---@param insert_strut boolean
---
---@return HlistNode|nil hlist_node
---@return Node|nil strut_node
---@return Node|nil prev_head_node
local function search_hlist(head_node, insert_strut)
while head_node do
if head_node.id == node.id('hlist') and head_node.subtype == 1 then
---@cast head_node HlistNode
if insert_strut then
return insert_strut_into_hlist(head_node)
else
return head_node
end
end
head_node = head_node.next
end
end
---
---See nodetree
---@param n Node
local function debug_node_list(n)
if log.get() < 5 then
return
end
local is_cloze = false
---@param head_node Node
local function get_textual_from_glyph(head_node)
local properties = node.direct.get_properties_table()
local node_id = node.direct.todirect(head_node) -- Convert to node id
local props = properties[node_id]
local info = props and props.glyph_info
local textual
local character_index = node.direct.getchar(node_id)
if info then
textual = info
elseif character_index == 0 then
textual = '^^@'
elseif character_index <= 31 or
(character_index >= 127 and character_index <= 159) then
textual = '???'
elseif character_index < 0x110000 then
textual = utf8.char(character_index)
else
textual = string.format('^^^^^^%06X', character_index)
end
return textual
end
local output = {}
---
---@param value string
local add = function(value)
table.insert(output, value)
end
local red = ansi_color.red
local green = ansi_color.green
local yellow = ansi_color.yellow
local blue = ansi_color.blue
local magenta = ansi_color.magenta
local cyan = ansi_color.cyan
while n do
local marker_data =
config.get_marker_data(n --[[@as UserDefinedWhatsitNode]] )
if marker_data then
if marker_data.position == 'start' then
is_cloze = true
else
is_cloze = false
end
end
if n.id == node.id('glyph') then
if is_cloze then
add(yellow(get_textual_from_glyph(n)))
else
add(get_textual_from_glyph(n))
end
elseif n.id == node.id('glue') then
add(' ')
elseif n.id == node.id('disc') then
add(cyan('|'))
elseif n.id == node.id('kern') then
add(blue('<'))
elseif marker_data then
local char
if marker_data.position == 'start' then
char = 'START'
else
char = 'STOP'
end
add(magenta('[' .. char .. ']'))
elseif n.id == node.id('whatsit') and n.subtype ==
node.subtype('pdf_colorstack') then
local c = n --[[@as PdfColorstackWhatsitNode]]
local command
if c.command == 1 then
command = 'push'
elseif c.command == 2 then
command = 'pop'
end
add(green('[' .. command .. ']'))
elseif n.id == node.id('rule') then
add(red('_'))
elseif n.id == node.id('hlist') then
debug_node_list(n.head)
add(red('└'))
end
n = n.next
end
print(table.concat(output, ''))
end
return {
debug_node_list = debug_node_list,
insert_list = insert_list,
create_color = create_color,
insert_line = insert_line,
write_line_nodes = write_line_nodes,
write_linefil_nodes = write_linefil_nodes,
create_line = create_line,
create_kern_node = create_kern_node,
insert_strut_into_hlist = insert_strut_into_hlist,
write_margin_node = write_margin_node,
search_hlist = search_hlist,
}
end)()
local visitor = (function()
---The enviroment in the node list where the cloze can be inserted.
---@class ClozeNodeEnvironment
---@field parent_hlist HlistNode
---@field start_marker? UserDefinedWhatsitNode
---@field start_node? Node
---@field start Node
---@field start_continuation boolean
---@field stop_node? Node
---@field stop_marker? UserDefinedWhatsitNode
---@field stop Node
---@field stop_continuation boolean True if the stop node is not a start marker. The cloze ends because the end of the line is reached.
---@field width integer The width in scaled points from the start to the stop node.
---@alias Visitor fun(env: ClozeNodeEnvironment): nil
---@param visitor Visitor
---@param parent_hlist? HlistNode
---@param start_marker? UserDefinedWhatsitNode
---@param start_node? Node
---@param stop_node? Node
---@param stop_marker? UserDefinedWhatsitNode
local function call_visitor(visitor,
parent_hlist,
start_marker,
start_node,
stop_node,
stop_marker)
local start --[[@as Node]]
if start_marker ~= nil then
start = start_marker
else
start = start_node
end
if start == nil then
error()
end
local stop --[[@as Node]]
if stop_marker ~= nil then
stop = stop_marker
else
stop = stop_node
end
if stop == nil then
error()
end
local width
if parent_hlist then
width = node.dimensions(parent_hlist.glue_set,
parent_hlist.glue_sign, parent_hlist.glue_order, start, stop)
else
width = node.dimensions(start, stop)
end
local env = {
parent_hlist = parent_hlist,
start_marker = start_marker,
start_node = start_node,
start = start,
start_continuation = start_marker == nil,
stop_node = stop_node,
stop_marker = stop_marker,
stop = stop,
stop_continuation = stop_marker == nil,
width = width,
}
visitor(env)
end
---@param n? Node
local function debug_node(n)
if n == nil then
return nil
end
return node.type(n.id)
end
---@param parent_hlist Node
---@param start_marker? Node
---@param start_node? Node
---@param stop_node? Node
---@param stop_marker? Node
local function debug_visitor(parent_hlist,
start_marker,
start_node,
stop_node,
stop_marker)
print(debug_node(parent_hlist), debug_node(start_marker),
debug_node(start_node), debug_node(stop_node),
debug_node(stop_marker))
end
---This local variables are overloaded by functions
---calling each other.
local search_stop
---
---Search for a stop marker or make a cloze up to the end of the node
---list.
---
---@param visitor Visitor
---@param mode MarkerMode
---@param parent_node HlistNode # The parent node (hlist) of the start node.
---@param start_marker? Node|nil
---@param start_node? Node # The node to start a new cloze.
---
---@return Node|nil head_node # The fast forwarded new head of the node list.
---@return Node|nil parent_node # The parent node (hlist) of the head node.
function search_stop(visitor,
mode,
parent_node,
start_marker,
start_node)
---@type Node|nil
local n
if start_marker ~= nil then
n = start_marker
else
n = start_node
end
local last_node
while n do
if config.check_marker(n, mode, 'stop') then
call_visitor(visitor, parent_node, start_marker --[[@as UserDefinedWhatsitNode]] ,
start_node, nil, n --[[@as UserDefinedWhatsitNode]] )
return n, parent_node
end
last_node = n
n = n.next
end
call_visitor(visitor, parent_node, start_marker --[[@as UserDefinedWhatsitNode]] ,
start_node, last_node, nil)
if parent_node.next then
local hlist_node = utils.search_hlist(parent_node.next, false)
if hlist_node then
local start_node = hlist_node.head
return search_stop(visitor, mode, hlist_node, nil, start_node)
end
else
return n, parent_node
end
end
---
---Search for a start marker.
---
---@param visitor Visitor
---@param mode MarkerMode
---@param head_node Node # The head of a node list.
---@param parent_node? HlistNode # The parent node (hlist) of the head node.
local function visit(visitor, mode, head_node, parent_node)
---@type Node|nil
local n = head_node
---@type Node|nil
local p = parent_node
while n do
if n.head then
---@cast n HlistNode
visit(visitor, mode, n.head, n)
elseif config.check_marker(n, mode, 'start') and p and p.id ==
node.id('hlist') then
---@cast p HlistNode
n, p = search_stop(visitor, mode, p, n, nil)
end
if n then
n = n.next
else
break
end
end
end
---
---Traverse a flat node list such as the one in the
---`pre_linebreak_filter` callback.
---
---@param visitor Visitor
---@param mode MarkerMode
---@param head_node Node # The head of a node list.
local function visit_pre_linebreak(visitor, mode, head_node)
---@type Node|nil
local n = head_node
local start_marker = nil
local stop_marker = nil
while n do
if config.check_marker(n, mode, 'start') then
start_marker = n
end
if config.check_marker(n, mode, 'stop') then
stop_marker = n
end
if start_marker and stop_marker then
call_visitor(visitor, nil, start_marker --[[@as UserDefinedWhatsitNode]] ,
nil, nil, stop_marker --[[@as UserDefinedWhatsitNode]] )
start_marker = nil
stop_marker = nil
end
n = n.next
end
end
return { visit = visit, visit_pre_linebreak = visit_pre_linebreak }
end)()
---
---Assemble a possibly multi-line cloze text.
---
---The corresponding LaTeX command to this Lua function is `\cloze`.
---This function is used by other cloze TeX macros too: `\clozenol`,
---`\clozefil`
---
---@param head_node_input Node # The head of a node list.
---
---@return Node # The head of the node list.
local function make_basic(head_node_input)
utils.debug_node_list(head_node_input)
---This local variables are overloaded by functions
---calling each other.
local continue_cloze, search_stop
---
---Make a single gap.
---
---@param start_node Node # The node to start / begin a new cloze.
---@param stop_node Node # The node to stop / end a new cloze.
---@param parent_node HlistNode # The parent node (hlist) of the start and the stop node.
---
---@return Node|nil stop_node # The stop node.
---@return HlistNode parent_node # The parent node (hlist) of the stop node.
local function make_single(start_node, stop_node, parent_node)
local node_head = start_node
local line_width = node.dimensions(parent_node.glue_set,
parent_node.glue_sign, parent_node.glue_order, start_node,
stop_node)
log.info('Make a line of the width of: %s sp', line_width)
local line_node = utils.insert_line(start_node, line_width)
local color_text_node = utils.insert_list('after', line_node, {
utils.create_color('text', 'push'),
})
if config.get('visibility') then
utils.insert_list('after', color_text_node,
{ utils.create_kern_node(-line_width) })
utils.insert_list('before', stop_node,
{ utils.create_color('text', 'pop') }, node_head)
else
line_node.next = stop_node.next
stop_node.prev = line_node -- not line_node.prev -> line color leaks out
end
---In some edge cases the lua callbacks get fired up twice. After the
---cloze has been created, the start and stop whatsit markers can be
---deleted.
config.remove_marker(start_node)
return config.remove_marker(stop_node), parent_node
end
---
---Search for a stop marker or make a cloze up to the end of the node
---list.
---
---@param start_node Node # The node to start a new cloze.
---@param parent_node HlistNode # The parent node (hlist) of the start node.
---
---@return Node|nil head_node # The fast forwarded new head of the node list.
---@return Node|nil parent_node # The parent node (hlist) of the head node.
function search_stop(start_node, parent_node)
---@type Node|nil
local n = start_node
local last_node
while n do
if config.check_marker(n, 'basic', 'stop') then
return make_single(start_node, n, parent_node)
end
last_node = n
n = n.next
end
-- Make a cloze until the end of the node list.
n = make_single(start_node, last_node, parent_node)
if parent_node.next then
return continue_cloze(parent_node.next)
else
return n, parent_node
end
end
---
---Continue a multiline cloze.
---
---@param parent_node Node # A parent node to search for a hlist node.
---
---@return Node|nil head_node # The fast forwarded new head of the node list.
---@return Node|nil parent_node # The parent node (hlist) of the head node.
function continue_cloze(parent_node)
local hlist_node = utils.search_hlist(parent_node, true)
if hlist_node then
local start_node = hlist_node.head
return search_stop(start_node, hlist_node)
end
end
---
---Search for a start marker.
---
---@param head_node Node # The head of a node list.
---@param parent_node? HlistNode # The parent node (hlist) of the head node.
local function search_start(head_node, parent_node)
---@type Node|nil
local n = head_node
---@type Node|nil
local p = parent_node
while n do
if n.head then
---@cast n HlistNode
search_start(n.head, n)
elseif config.check_marker(n, 'basic', 'start') and p and p.id ==
node.id('hlist') then
---Adds also a strut at the first position. It prepars the
---hlist and makes it ready to build a cloze.
---@cast p HlistNode
utils.search_hlist(p, true)
n, p = search_stop(n, p)
end
if n then
n = n.next
else
break
end
end
end
search_start(head_node_input)
return head_node_input
end
---
---Enlarge a basic cloze by a spread factor.
---
---We measure the widths of the cloze, calculate the spread width and
---then simply put half of that to the left and right of the cloze if
---the text is typeset.
---
---@param head_node_input Node # The head of a node list.
local function spread_basic(head_node_input)
local function recurse(head_node)
local n = head_node
local m
while n do
if n.head then
recurse(n.head)
elseif config.check_marker(n, 'basic', 'start') then
local start = n
m = n
while m do
if config.check_marker(m, 'basic', 'stop') then
local stop = m
local width = node.dimensions(start, stop)
local spread = config.get('spread')
if spread == 0 then
break
end
local spread_half_width = (width * spread) / 2
local start_kern = utils.create_kern_node(spread_half_width)
local start_next = start.next
start.next = start_kern
start_kern.next = start_next
local stop_kern = utils.create_kern_node(spread_half_width)
local stop_prev = stop.prev
stop_prev.next = stop_kern
stop_kern.next = stop
break
end
m = m.next
end
end
if n then
n = n.next
else
break
end
end
end
recurse(head_node_input)
return head_node_input
end
---
---The corresponding LaTeX command to this Lua function is `\clozefix`.
---
---@param head_node_input Node # The head of a node list.
local function make_fix(head_node_input)
---
---Calculate the widths of the whitespace before (`start_width`) and
---after (`stop_width`) the cloze text.
---
---@param start Node
---@param stop Node
---
---@return integer width
---@return integer start_width # The width of the whitespace before the cloze text.
---@return integer stop_width # The width of the whitespace after the cloze text.
local function calculate_widths(start, stop)
local start_width, stop_width
local width = tex.sp(config.get('width'))
local text_width = node.dimensions(start, stop)
local align = config.get('align')
if align == 'right' then
start_width = -text_width
stop_width = 0
elseif align == 'center' then
local half = (width - text_width) / 2
start_width = -half - text_width
stop_width = half
else
start_width = -width
stop_width = width - text_width
end
return width, start_width, stop_width
end
---
---Generate a gap with a fixed width.
---
---# Node lists
---
---## Show text:
---
---| Variable name | Node type | Node subtype | |
---|--------------------|-----------|--------------------|-------------|
---| `start_node` | `whatsit` | `user_definded` | `index` |
---| `line_node` | `rule` | | `width` |
---| `kern_start_node` | `kern` | Depends on `align` | |
---| `color_text_node` | `whatsit` | `pdf_colorstack` | Text color |
---| | `glyphs` | Text to show | |
---| `color_reset_node` | `whatsit` | `pdf_colorstack` | Reset color |
---| `kern_stop_node` | `kern` | Depends on `align` | |
---| `stop_node` | `whatsit` | `user_definded` | `index` |
---
---## Hide text:
---
---| Variable name | Node type | Node subtype | |
---|---------------|-----------|-----------------|---------|
---| `start_node` | `whatsit` | `user_definded` | `index` |
---| `line_node` | `rule` | | `width` |
---| `stop_node` | `whatsit` | `user_definded` | `index` |
---
---@param start Node # The node, where the gap begins
---@param stop Node # The node, where the gap ends
local function make_single(start, stop)
local width, kern_start_length, kern_stop_length = calculate_widths(
start, stop)
local line_node = utils.insert_line(start, width)
if config.get('visibility') then
utils.insert_list('after', line_node, {
utils.create_kern_node(kern_start_length),
utils.create_color('text', 'push'),
})
utils.insert_list('before', stop, {
utils.create_color('text', 'pop'),
utils.create_kern_node(kern_stop_length),
}, start)
else
line_node.next = stop.next
end
config.remove_marker(start)
config.remove_marker(stop)
end
---
---Recurse the node list and search for the marker.
---
---@param head_node Node # The head of a node list.
local function make_fix_recursion(head_node)
---@type Node|false
local start_node = false
---@type Node|false
local stop_node = false
while head_node do
if head_node.head then
make_fix_recursion(head_node.head)
else
if not start_node then
start_node = config.get_marker(head_node, 'fix', 'start')
end
if not stop_node then
stop_node = config.get_marker(head_node, 'fix', 'stop')
end
if start_node and stop_node then
make_single(start_node, stop_node)
start_node, stop_node = false, false
end
end
head_node = head_node.next
end
end
make_fix_recursion(head_node_input)
return head_node_input
end
---
---
---visibilty = true
---
---```
---├─WHATSIT (user_defined) user_id 3121978, type 100, value 3
---├─VLIST (unknown) wd 77.93pt, dp 2.01pt, ht 18.94pt
---│ ╚═head
---│ ├─HLIST (box) wd 21.85pt, dp 0.11pt, ht 6.94pt
---│ │ ╚═head
---│ │ ├─WHATSIT (pdf_colorstack) data '0 0 1 rg 0 0 1 RG'
---│ │ ├─KERN (userkern) 28.04pt
---│ │ ├─GLYPH (glyph) 't', font 19, wd 4.09pt, ht 4.42pt, dp 0.11pt
---│ │ ├─GLYPH (glyph) 'o', font 19, wd 5.11pt, ht 6.94pt, dp 0.11pt
---│ │ ├─GLYPH (glyph) 'p', font 19, wd 5.11pt, ht 4.42pt, dp 0.11pt
---│ │ └─WHATSIT (pdf_colorstack) data ''
---│ ├─GLUE (baselineskip) wd 4.95pt
---│ └─HLIST (box) wd 77.93pt, dp 2.01pt, ht 6.94pt
---│ ╚═head
---│ ├─WHATSIT (pdf_colorstack) data '0 0 1 rg 0 0 1 RG'
---│ ├─RULE (normal) wd 77.93pt, dp -2.31pt, ht 2.71pt
---│ ├─WHATSIT (pdf_colorstack) data ''
---│ ├─KERN (fontkern) -77.93pt
---│ ├─GLYPH (glyph) 'b', font 20, wd 3.19pt, ht 6.94pt
---│ ├─GLYPH (glyph) 'a', font 20, wd 5.75pt, ht 4.53pt, dp 0.06pt
---│ ├─GLYPH (glyph) 's', font 20, wd 6.39pt, ht 4.5pt
---│ ├─GLYPH (glyph) 'e', font 20, wd 5.75pt, ht 4.55pt, dp 2.01pt
---│ └─KERN (italiccorrection)
---├─RULE (normal) dp 3.6pt, ht 8.4pt
---├─WHATSIT (user_defined) user_id 3121978, type 100, value 4
---```
---
---visibilty = false
---
---```
---├─WHATSIT (user_defined) user_id 3121978, type 100, value 3
---├─VLIST (unknown) wd 77.93pt, dp 2.01pt, ht 18.94pt
---│ ╚═head
---│ ├─HLIST (box) wd 21.85pt, dp 0.11pt, ht 6.94pt
---│ ├─GLUE (baselineskip) wd 4.95pt
---│ └─HLIST (box) wd 77.93pt, dp 2.01pt, ht 6.94pt
---│ ╚═head
---│ ├─GLYPH (glyph) 'b', font 20, wd 3.19pt, ht 6.94pt
---│ ├─GLYPH (glyph) 'a', font 20, wd 5.75pt, ht 4.53pt, dp 0.06pt
---│ ├─GLYPH (glyph) 's', font 20, wd 6.39pt, ht 4.5pt
---│ ├─GLYPH (glyph) 'e', font 20, wd 5.75pt, ht 4.55pt, dp 2.01pt
---│ └─KERN (italiccorrection)
---├─RULE (normal) dp 3.6pt, ht 8.4pt
---├─WHATSIT (user_defined) user_id 3121978, type 100, value 4
---```
---
---@param head_node Node
---
---@return Node head_node
local function make_strike(head_node)
visitor.visit_pre_linebreak(function(env)
local text_color = farbe.Color(config.get('text_color'))
local vlist = env.start.next --[[@as VlistNode]]
local top_hlist = vlist.head --[[@as HlistNode]]
local baselineskip = top_hlist.next --[[@as GlueNode]]
local base_hlist = baselineskip.next --[[@as HlistNode]]
local top_kern = top_hlist.head --[[@as KernNode]]
if top_hlist.width > base_hlist.width then
-- top long
-- short
vlist.width = base_hlist.width
top_kern.kern = -(top_hlist.width - base_hlist.width) / 2
else
-- top
-- base long
top_kern.kern = (base_hlist.width - top_hlist.width) / 2
end
-- top
local top_start = top_hlist.head
if config.get('visibility') then
-- top color
top_hlist.head = text_color:create_pdf_colorstack_node('push')
top_hlist.head.next = top_start
local top_stop = node.tail(top_hlist.head)
top_stop.next = text_color:create_pdf_colorstack_node('pop')
else
top_hlist.head = nil
end
-- strike line
if config.get('visibility') then
local base_start = base_hlist.head
local base_stop = node.tail(base_hlist.head)
local width, height, _ = node.dimensions(base_start, base_stop)
local line = node.new('rule') --[[@as RuleNode]]
local thickness = tex.sp(config.get('thickness'))
line.depth = -(height / 3)
line.height = (height / 3) + thickness
line.width = width
base_hlist.head = text_color:create_pdf_colorstack_node('push')
local color_pop = text_color:create_pdf_colorstack_node('pop')
local kern = utils.create_kern_node(-width)
base_hlist.head.next = line
line.next = color_pop
color_pop.next = kern
kern.next = base_start
end
end, 'strike', head_node)
return head_node
end
---
---The corresponding LaTeX environment to this lua function is
---`clozepar`.
---
---# Node lists
---
---## Show text:
---
---| Variable name | Node type | Node subtype | |
---|--------------------|-----------|------------------|----------------------------|
---| `strut_node` | `kern` | | width = 0 |
---| `line_node` | `rule` | | `width` (Width from hlist) |
---| `kern_node` | `kern` | | `-width` |
---| `color_text_node` | `whatsit` | `pdf_colorstack` | Text color |
---| | `glyphs` | | Text to show |
---| `tail_node` | `glyph` | | Last glyph in hlist |
---| `color_reset_node` | `whatsit` | `pdf_colorstack` | Reset color
---
---## Hide text:
---
---| Variable name | Node type | Node subtype | |
---|---------------|------------|--------------|----------------------------|
---| `strut_node` | `kern` | | width = 0 |
---| `line_node` | `rule` | | `width` (Width from hlist) |
---
---@param head_node Node # The head of a node list.
local function make_par(head_node)
utils.debug_node_list(head_node)
---
---Add one additional empty line at the end of a paragraph.
---
---All fields from the last hlist node are copied to the created
---hlist.
---
---@param last_hlist_node HlistNode # The last hlist node of a paragraph.
---
---@return HlistNode # The created new hlist node containing the line.
local function add_additional_line(last_hlist_node)
local hlist_node = node.new('hlist') --[[@as HlistNode]]
hlist_node.subtype = 1
local fields = {
'width',
'depth',
'height',
'shift',
'glue_order',
'glue_set',
'glue_sign',
'dir',
}
for _, field in ipairs(fields) do
if last_hlist_node[field] then
hlist_node[field] = last_hlist_node[field]
end
end
local kern_node = utils.create_kern_node(0)
hlist_node.head = kern_node
utils.insert_line(kern_node, last_hlist_node.width)
last_hlist_node.next = hlist_node
hlist_node.prev = last_hlist_node
hlist_node.next = nil
return hlist_node
end
---
---Add multiple empty lines at the end of a paragraph.
---
---@param last_hlist_node HlistNode # The last hlist node of a paragraph.
---@param count number # Count of the lines to add at the end.
local function add_additional_lines(last_hlist_node,
count)
local i = 0
while i < count do
last_hlist_node = add_additional_line(last_hlist_node)
i = i + 1
end
end
---@type Node
local strut_node
---@type Node
local line_node
---@type number
local width
---@type HlistNode
local last_hlist_node
---@type HlistNode
local hlist_node
local line_count = 0
while head_node do
if head_node.id == node.id('hlist') then
---@cast head_node HlistNode
hlist_node = head_node
line_count = line_count + 1
last_hlist_node = hlist_node
width = hlist_node.width
hlist_node, strut_node, _ = utils.insert_strut_into_hlist(
hlist_node)
line_node = utils.insert_line(strut_node, width)
if config.get('visibility') then
utils.insert_list('after', line_node, {
utils.create_kern_node(-width),
utils.create_color('text', 'push'),
})
utils.insert_list('after', node.tail(line_node),
{ utils.create_color('text', 'pop') })
else
line_node.next = nil
end
end
head_node = head_node.next
end
local min_lines = config.get('min_lines')
local additional_lines = min_lines - line_count
if additional_lines > 0 then
add_additional_lines(last_hlist_node, additional_lines)
end
return true
end
local cb = (function()
---
---@param callback_name CallbackName # The name of a callback
---@param func function # A function to register for the callback
---@param description string # Only used in LuaLatex
local function register(callback_name, func, description)
if luatexbase then
luatexbase.add_to_callback(callback_name, func, description)
else
callback.register(callback_name, func)
end
end
---
---@param callback_name CallbackName # The name of a callback
---@param description string # Only used in LuaLatex
local function unregister(callback_name, description)
if luatexbase then
luatexbase.remove_from_callback(callback_name, description)
else
callback.register(callback_name, nil)
end
end
---
---Store informations if the callbacks are already registered for
---a certain mode (`basic`, `fix`, `par`).
---
---@type table<'basic'|'fix'|'par'|'visitor', boolean>
local is_registered = {}
return {
---
---Register the functions `make_par`, `make_basic` and
---`make_fix` as callbacks.
---
---`make_par` and `make_basic` are registered to the callback
---`post_linebreak_filter` and `make_fix` to the callback
---`pre_linebreak_filter`. The argument `mode` accepts the string values
---`basic`, `fix` and `par`. A special treatment is needed for clozes in
---display math mode. The `post_linebreak_filter` is not called on
---display math formulas. I’m not sure if the `pre_output_filter` is the
---right choice to capture the display math formulas.
---
---@param mode MarkerMode
---
---@return boolean|nil
register_callbacks = function(mode)
if mode == 'par' then
register('post_linebreak_filter', make_par, mode)
return true
end
if not is_registered[mode] then
if mode == 'basic' then
register('pre_linebreak_filter', spread_basic, mode)
register('post_linebreak_filter', make_basic, mode)
register('pre_output_filter', make_basic, mode)
elseif mode == 'fix' then
register('pre_linebreak_filter', make_fix, mode)
elseif mode == 'strike' then
register('pre_linebreak_filter', make_strike, mode)
else
return false
end
is_registered[mode] = true
end
end,
---
---Delete the registered functions from the Lua callbacks.
---
---@param mode MarkerMode
unregister_callbacks = function(mode)
if mode == 'basic' then
unregister('pre_linebreak_filter', mode)
unregister('post_linebreak_filter', mode)
unregister('pre_output_filter', mode)
elseif mode == 'fix' then
unregister('pre_linebreak_filter', mode)
elseif mode == 'strike' then
unregister('pre_linebreak_filter', mode)
else
unregister('post_linebreak_filter', mode)
end
end,
}
end)()
---
---Variable that can be used to store the previous fbox rule thickness
---to be able to restore the previous thickness.
local fboxrule_restore
local function print_cloze()
local kv_string, text = lparse.scan('O{} v')
config.parse_options(kv_string, 'local')
cb.register_callbacks('basic')
local output = string.format(
'\\ClozeStartMarker{basic}%s\\ClozeStopMarker{basic}',
string.format('{\\clozefont\\relax%s}',
string.format('\\ClozeMargin{%s}', text)))
tex.print(output)
end
local function print_strike()
local kv_string, error_text, solution_text = lparse.scan('O{} v v')
config.parse_options(kv_string, 'local')
cb.register_callbacks('strike')
tex.print('\\ClozeStartMarker{strike}' .. string.format(
'\\vbox{\\hbox{\\kern0pt \\ClozeWrapWithFont{%s}}\\hbox{%s}}',
solution_text, error_text) .. '\\ClozeStopMarker{strike}')
end
---
---This table contains some basic functions which are published to the
---`cloze.tex` and `cloze.sty` file.
return {
register_functions = function()
---
---@param csname string
---@param fn function
local function register_function(csname, fn)
local index = 376
local fns = lua.get_functions_table()
while fns[index] do
index = index + 1
end
fns[index] = fn
token.set_lua(csname, index, 'global', 'protected')
end
register_function('clozeNG', print_cloze)
register_function('clozestrike', print_strike)
end,
write_linefil_nodes = utils.write_linefil_nodes,
write_line_nodes = utils.write_line_nodes,
write_margin_node = utils.write_margin_node,
set_option = config.set_option,
set_options_dest = config.set_options_dest,
unset_local_options = config.unset_local_options,
reset = config.unset_global_options,
get_defaults = config.get_defaults,
get_option = config.get,
marker = config.write_marker,
parse_options = config.parse_options,
register_callback = cb.register_callbacks,
unregister_callback = cb.unregister_callbacks,
---@param count string|number
print_extension = function(count)
---@type number|nil
local c
if count == '' then
c = config.get('extension_count')
end
c = tonumber(count)
if not c then
luakeys.utils.throw_error_message(
'clozeextend count must be greater than 0.')
end
for _ = 1, c do
---ex: vertical measure of x
---px: x height current font (has no effect)
tex_printf('\\hspace{%s}\\rule{0pt}{%s}',
config.get('extension_width'), config.get('extension_height'))
end
end,
---
---@param text string
---@param kv_string string
---@param starred string # `\BooleanTrue` `\BooleanFalse`
print_box = function(text, kv_string, starred)
log.debug('text: %s kv_string: %s starred: %s', text, kv_string,
starred)
config.set_options_dest('local')
config.defs_manager:parse(kv_string, {
'visibility',
box_rule = 'rule',
box_width = 'width',
box_height = 'height',
})
fboxrule_restore = tex.dimen['fboxrule']
local rule = config.get('box_rule')
if rule then
tex.dimen['fboxrule'] = tex.sp(rule)
end
tex.print('\\noindent')
tex.print('\\begin{lrbox}{\\ClozeBox}')
local height = config.get('box_height')
local width = config.get('box_width')
if height then
tex_printf('\\begin{minipage}[t][%s][t]{%s}', height, width)
else
tex_printf('\\begin{minipage}[t]{%s}', width)
end
tex.print('\\setlength{\\parindent}{0pt}')
tex_printf('\\clozenol[margin=0pt]{%s}', text)
tex.print('\\end{minipage}')
tex.print('\\end{lrbox}')
if starred:match('True') then
tex.print('\\usebox{\\ClozeBox}')
else
tex.print('\\fbox{\\usebox{\\ClozeBox}}')
end
end,
---
---Print the required TeX markup for the environment `clozespace` using `tex.print()`
---
---@param kv_string string
print_space = function(kv_string)
config.set_options_dest('local')
local defs = config.defs_manager:include({ 'spacing' }, true)
defs.spacing.pick = 'number'
luakeys.parse(kv_string, { defs = defs })
tex_printf('\\begin{spacing}{%s}', config.get('spacing'))
end,
restore_fboxrule = function()
tex.dimen['fboxrule'] = fboxrule_restore
end,
}