-- 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, }