summaryrefslogtreecommitdiff
path: root/macros/luatex/latex/longmath/longmath.lua
blob: 56c64c8cdb148e6de964edc5f6359d4bfe17c4a2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
--
--  longmath.lua is part of longmath version 0.1. 
--
--  (c) 2024 Hans-Jürgen Matschull
--
--  This work may be distributed and/or modified under the
--  conditions of the LaTeX Project Public License, either version 1.3
--  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.3 or later is part of all distributions of LaTeX
--  version 2005/12/01 or later.
-- 
--  This work has the LPPL maintenance status 'maintained'.
--  
--  The Current Maintainer of this work is Hans-Jürgen Matschull
-- 
--  see README for a list of files belonging to longmath.
--

-- Attribute numbers. 
local attr_info = luatexbase.registernumber( 'longmath@info' )
local attr_limits = luatexbase.registernumber( 'longmath@limits' )

-- Character for tagging parent groups. 
local parent = '+'


-- Check for specific node types. 
local ntypes = node.types()
local function is_noad( nd )  return nd and ntypes[ nd.id ] == 'noad' and nd.subtype end 
local function is_fence( nd ) return nd and ntypes[ nd.id ] == 'fence' and nd.subtype end 
local function is_hlist( nd ) return nd and ntypes[ nd.id ] == 'hlist' and nd.subtype end 
local function is_style( nd ) return nd and ntypes[ nd.id ] == 'style' and nd.subtype end 
local function is_whatsit( nd ) return nd and ntypes[ nd.id ] == 'whatsit' and nd.subtype end 

-- Read a command whatsit. 
local function get_comm( nd ) 
  if is_whatsit( nd ) == 3 and node.has_attribute( nd, attr_info ) then 
    local comm, data = nd.data:match( '^([^:]+):?(.*)$' )
    if data == '' then data = nil end 
    return comm, data, node.get_attribute( nd, attr_info )   
  end 
end 

-- Iterator over all fields of a node to be copied. 
-- Returns the field name and a boolean indicating if a deep copy is needed.
-- If the node already has fixed dimensions, its head field is ignored.  
-- If the limits attribute is set on a large operator, scripts are ignored in display style. 
local function fields( nd, disp )
  local list, oper = {}, is_noad( nd )   
  for _, fld in ipairs( node.fields( nd.id, nd.subtype ) ) do 
    list[fld] = not not node.is_node( nd[fld] ) 
  end 
  list.id, list.subtype, list.attr = nil, nil, list.attr and false 
  if list.height ~= nil and list.depth ~= nil then list.head = nil end
  if ( disp and oper == 1 or oper == 2 ) and node.has_attribute( nd, attr_limits ) then 
    list.sub, list.sup = nil, nil 
  end
  return pairs( list ) 
end 

-- Tables representing special delimiters. 
local delim_null = { small_fam = 0, small_char = 0, large_fam = 0, large_char = 0 }
local delim_auto = { small_fam = 0xF, small_char = 0xEF, large_fam = 0xE, large_char = 0xFE }

-- Check if two delimiters (either real nodes or tables) are equal. 
local function delim_eq( da, db ) 
  for i in pairs( delim_null ) do if da[i] ~= db[i] then return false end end 
  return true
end 

-- Make the delimiter node or table da equal to db.  
local function delim_set( da, db ) 
  for i in pairs( delim_null ) do da[i] = db[i] end 
  return da 
end 

-- Metatable for delimiter sequences. Internalises the overflow check.  
local meta_delims = {}
function meta_delims.__index( tab, ix ) 
  if type( ix ) ~= 'number' then return nil end 
  if ix > #tab then ix = #tab end 
  if ix < 1 then ix = 1 end 
  return rawget( tab, ix ) 
end 

-- Stack to store auto delimiters. 
-- The last entry in this table is the current one.
-- It contains two tables "opn" and "cls". 
-- Each contains a sequence of delimiter data for each level.   
local delimiters = {} 

-- Scans a sample of auto delimiters. 
-- Inserts the opening and closing delimites into the "opn" and "cls" tables in "tab". 
local function scan_sample( head, tab )
  local opn, cls  
  for pos in node.traverse( head ) do
    if is_fence( pos ) == 1 then opn = delim_set( {}, pos.delim )  
    elseif is_fence( pos ) == 3 then cls = delim_set( {}, pos.delim )
    elseif is_noad( pos ) == 9 then scan_sample( pos.nucleus.head, tab ) end 
  end 
  if opn and cls then table.insert( tab.opn, opn ) table.insert( tab.cls, cls ) end 
end 

-- This function is triggered by a "set" command. 
-- The "sample" is a node containing a nested list if delimiter groups. 
-- Push a new item on the stack and scan the sample. 
local function set_auto( sample )
  local tab = { opn = setmetatable( {}, meta_delims ), cls = setmetatable( {}, meta_delims ) }
  table.insert( delimiters, tab )
  scan_sample( sample, tab )
end 

-- This function is triggered by a "res" command.
-- Remove the topmost item from the stack. 
local function res_auto()
  table.remove( delimiters )
end 

-- Adapt a fake delimiter group to the given data. 
-- "head" is the node containing the "\math___{}" object. 
-- "level" is the nesting level to be used if this is an auto delimiter. 
-- "ht" and "dp" are the height and depth of the content of the group. 
-- Returns the level if this was an auto delimiter 
--    or a fixed one equal to the auto delimiter of some level.  
local function set_delim( head, level, ht, dp ) 
  local type, delim, hbox = head.subtype == 6 and 'opn' or head.subtype == 7 and 'cls'
  while is_noad( head ) do 
    node.unset_attribute( head, attr_info ) head = head.nucleus
    node.unset_attribute( head, attr_info ) head = head.head
  end 
  for pos in node.traverse( head ) do 
    node.unset_attribute( pos, attr_info ) 
    if is_fence( pos ) == 3 then delim = pos.delim end 
    if is_noad( pos ) == 0 then hbox = pos.nucleus.head end
  end 
  if not ( delim and hbox ) then return end 
  local auto = delim_eq( delim, delim_auto )
  local brks = delimiters[#delimiters]
  if brks then brks = brks[type] end 
  if type and auto and brks then 
    delim_set( delim, brks[level] ) 
  elseif type and brks then  
    level = nil 
    for l = 1, #brks do if delim_eq( brks[l], delim ) then level = l break end end
  else
    level = nil 
  end  
  local scale = node.get_attribute( hbox, attr_info ) 
  hbox.height, hbox.depth = ht * scale // 1000, dp * scale // 1000  
  if delim_eq( delim, delim_null ) then hbox.width = hbox.width * 2 end 
  return level 
end

-- This creates a deep copy of the node list from start to stop (inclusive).
-- Ignores whatsits and the content of nodes that have fixed dimension.
-- Ignores limits of large operators if flagged and "disp" is true.   
-- Ignores nodes that have the info attribute set. These are unprocessed delimiters.  
-- Tries to keep track of the current math style. 
local function copy_list( start, stop, disp ) 
  local old, new, copy, last = start 
  while old do    
    if is_style( old ) then disp = old.style:match( 'display' ) end 
    local ign = is_noad( old ) and node.has_attribute( old, attr_info ) or is_whatsit( old )  
    if not ign then   
      new = node.new( old.id, old.subtype )
      for field, deep in fields( old, disp ) do 
        if deep then 
          local disp = disp and ( field == 'nucleus' or field == 'head' or field == 'display' ) 
          new[field] = copy_list( old[field], nil, disp )
        else 
          new[field] = old[field]   
        end
      end 
      if not copy then last, copy = new, new
      else last, copy = new, node.insert_after( copy, last, new ) end 
    end 
    if old == stop then break end 
    old = node.next( old )
  end 
  return copy 
end 

-- Creates a math style node. 
local function style_node( style )
  local nd = node.new( "style" )
  nd.style = style 
  return nd 
end 

-- Copies the node list from "start" to "stop" and packs it into a temporary hbox. 
-- Returns the height and depth of that box when typeset in "style". 
local function dimensions( start, stop, style )
  local disp = style == 'display' or style == 0 or style == 1 
  local copy = copy_list( start, stop, disp )
  if not copy then return 0, 0 end 
  if style then copy = node.insert_before( copy, copy, style_node( style ) ) end 
  local box = node.mlist_to_hlist( copy, 'text', false )
  if not box then return 0, 0 end 
  local wd, ht, dp = node.dimensions( box )
  node.flush_list( box )
  return ht, dp 
end

-- Table containing information read from the aux file.
local oldgroups = {}
-- Table containing information to be written to the aux file.
local newgroups = {}
-- Table containing tables of tags that are synonyms for the same group. 
local equals = {} 

-- Merge information of a group with the inforation from the aux file 
--   and store the new information to be written to the aux file. 
-- A group table contains the following information: 
-- "tags": table of tags attached to the group (as keys with value "true").
-- "ht", "dp": dimensions
-- "lv": the maximal level of automatic delimiters used for subgroups.  
local function max( a, b ) return a and b and math.max( a, b ) or a or b end 
local function merge( group ) 
  local tags = group.tags
  if not tags or not next( tags ) then return end 
  if next( tags, ( next( tags ) ) ) then table.insert( equals, tags ) end 
  for tag in pairs( tags ) do 
    local ngrp = newgroups[tag] or {}
    newgroups[tag] = ngrp
    ngrp.ht = max( ngrp.ht, group.ht )
    ngrp.dp = max( ngrp.dp, group.dp )
    ngrp.lv = max( ngrp.lv, group.lv )
  end
  for tag in pairs( tags ) do 
    local ogrp = oldgroups[tag]
    if ogrp then 
      group.ht = max( group.ht, ogrp.ht )
      group.dp = max( group.dp, ogrp.dp )
      group.lv = max( group.lv, ogrp.lv )
    end 
  end
end 


-- Parses a math list and process all delimiters.
-- "pargrp" is the parent group. 
-- "head" is the head of the math list. 
-- "open" is the opening delimiter if this is a recursive call. 
-- "pos" is the position where to start the scan. 
-- "style" is the current math style, if known. 
--   This will otherwise be set when th first command is detected. 
-- "group" is the group table for this group. 
-- Returns a modified head, and sets information in the parent group. 
-- "delims" collects all delimiters belonging to this group. 
-- When a longmath special is detected, it is removed and the next node is processed: 
--   set/res: set or reset auto delimiters.
--   opn: an opening delimiter follows. The function is called recursively. 
--   cls: a closing delimiter follows. The current group ends 
--   mid: an inner delimiter follows. 
-- For every other node in the list, its subnodes are parsed recursively. 
local function parse( pargrp, head, open, pos, style, group ) 
  if not head then return end
  local delims, subgrp = { open }
  group = group or {}
  pos = pos or head 
  while pos do
    local comm, data, info = get_comm( pos )
    if comm then head, pos = node.remove( head, pos )
      if comm == 'set' then set_auto( pos ) return head end 
      if comm == 'res' then res_auto() return head end 
      style = style or info 
      group.tags = group.tags or {}
      if comm == 'opn' then
        local subgrp = { tags = {} }
        if data then subgrp.tags[data] = true end  
        head, pos = parse( group, head, pos, node.next( pos ), info, subgrp ) 
      elseif comm == 'mid' then 
        if data then group.tags[data] = true end  
        table.insert( delims, pos )
        pos = node.next( pos )
      elseif comm == 'cls' then 
        if data then group.tags[data] = true end  
        table.insert( delims, pos )
        break
      end 
    else  
      for field, deep in fields( pos ) do if deep then 
        pos[field] = parse( group, pos[field] )
      end end 
      pos = node.next( pos )
    end     
  end 
  -- if this is an actual delimiter group, measure its demensions. 
  if group.tags then group.ht, group.dp = dimensions( open or head, pos, style ) end 
  -- merge information with aux file 
  merge( group )
  -- finally set the delimiters and parse any scripts attached to them.
  local level, auto = group.lv or 0  
  for _, del in ipairs( delims ) do
    auto = set_delim( del, level+1, group.ht, group.dp ) or auto 
    del.sub = parse( group, del.sub )
    del.sup = parse( group, del.sup )
  end
  if pos and not open then 
    -- this group started without an opening delimiter. 
    -- we use a tail call and proceed as if there was one and this function was called from a parent. 
    pargrp = { lv = auto or group.lv, tags = {} }
    if group.tags then for tag in pairs( group.tags ) do pargrp.tags[tag..parent] = true end end 
    return parse( {}, head, nil, node.next( pos ), style, pargrp )   
  else  
    -- inform the parent about the auto delimiter level and any tags used in subgroups.  
    pargrp.lv = max( pargrp.lv, auto or group.lv ) 
    if pargrp.tags and group.tags then for tag in pairs( group.tags ) do pargrp.tags[tag..parent] = true end end 
    return head, pos  
  end 
end 
  
-- Callback that scans a math list. 
local function scan( head, style, pen )
  head = parse( {}, head, nil, nil, style ) 
  return node.mlist_to_hlist( head, style, true ) 
end 
luatexbase.add_to_callback( 'mlist_to_hlist', scan, 'longmath parse' )

-- Creates a glue node. 
local function glue_node( wd )
  local nd = node.new( "glue", 8 )
  nd.width = wd 
  return nd 
end 

-- Applies a stepwise shift to the hlists in a vlist if "extra" is non-zero.   
local function shift( head )
  local extra = tex.dimen['longmath@extra'] 
  if extra == 0 then return true end  
  local lines = {} 
  for nd in node.traverse( head ) do 
    if is_hlist( nd ) == 1 then table.insert( lines, nd ) end
  end 
  local n = #lines - 1
  if n > 0 then 
    for i, nd in ipairs( lines ) do  
      nd.width = nd.width + extra 
      nd.head = node.insert_before( nd.head, nd.head, glue_node( (i-1) * extra / n ) )
      nd.head = node.insert_after( nd.head, node.tail( nd.head ), glue_node( (n-i+1) * extra / n ) )
    end
  end 
  return true
end
luatexbase.add_to_callback( 'post_linebreak_filter', shift, 'longmath shift' )

-- Table for functions to be called from TeX. 
longmath = {}

-- Read data for a collection of identical groups. 
function longmath.read_group( tags, tab ) 
  for _, tag in ipairs( tags ) do oldgroups[tag] = tab end  
end

-- Check if a tag is not already in a set. 
-- If a subgroup or parent is in the set, inserts it into a the "loops" table. 
local loops = {}
local loop_patt = '[' .. parent .. ']*$'
local function is_new( tag, set )
  if set[tag] then return false end 
  tag = tag:gsub( loop_patt, '' )
  for tagg in pairs( set ) do if tag == tagg:gsub( loop_patt, '' ) then 
    loops[ tag:gsub( '@$', '' ) ] = true 
    return false 
  end end 
  return true 
end 

-- Given a set of tags attached to the same group, 
--   add all other tags attached to the same group to the set. 
local function find_eq( set )
  local new = {}
  for tag in pairs( set ) do 
    local app = ''
    while true do   
      for _, w in ipairs( equals ) do if w[tag] then for tagg in pairs( w ) do 
          if is_new( tagg..app, set ) then new[tagg..app] = true end 
      end end end 
      if tag:sub(-1) ~= parent then break end 
      tag, app = tag:sub(1,-2), app .. parent 
    end 
  end 
  if not next( new ) then return set end
  for tag in pairs( new ) do set[tag] = true end 
  return find_eq( set )
end

local loop_mess = '\\PackageWarningNoLine{longmath}{Cyclic delimiter group%s %s detected}'
local data_mess = '\\PackageWarningNoLine{longmath}{Delimiters may have changed. Rerun to get them right}'

-- Save all groups to the aux file.
-- Tags that belong to the same group are collected into a single entry.
function longmath.save_groups( aux )
  local check, done = true, {} 
  for tag, grp in pairs( newgroups ) do if not done[tag] then 
    local tags, vals, eqs = {}, {}, find_eq( { [tag] = true } ) 
    for tagg in pairs( eqs ) do 
      check, done[tagg] = check and oldgroups[tagg], true 
      table.insert( tags, string.format( '%q', tagg ) )
      while tagg do 
        if newgroups[tagg] then 
          grp.ht, grp.dp = max( grp.ht, newgroups[tagg].ht ), max( grp.dp, newgroups[tagg].dp )
          grp.lv = max( grp.lv, newgroups[tagg].lv )
        end 
        tagg = tagg:match( '^(.*)[' .. parent .. ']$' )
      end 
    end 
    for k, v in pairs( grp ) do  
      table.insert( vals, string.format( '%s=%d', k, v ) )
      for tagg in pairs( eqs ) do check = check and v == oldgroups[tagg][k] end 
    end
    tags, vals = table.concat( tags, ',' ), table.concat( vals, ',' ) 
    texio.write( aux, string.format( '\\longmath@group{%s}{%s}\n', tags, vals ) )
  end end 
  local lps = {}
  for l in pairs( loops ) do 
    if #lps > 5 then table.insert( lps, '...' ) break end 
    table.insert( lps, l )
  end 
  if #lps > 0 then 
    tex.print( string.format( loop_mess, #lps > 1 and 's' or '', table.concat( lps, ', ' ) ) )
  end 
  if not check then tex.print( data_mess ) end  
end 

return