luatexbase.provides_module{ name = 'KKsymbols', date = '2026/08/20', version = '2.3.0', } KKS = KKS or {} function KKS.narrow_ones(s) if s:match("^%d%d+$") then local res = {} for i = 1, #s do local char = s:sub(i,i) if char == "1" then table.insert(res, "{\\setbox0=\\hbox{1}\\hbox to 0.7\\wd0{\\hss\\box0\\hss}}" ) else table.insert(res, char) end end tex.print(table.concat(res)) else tex.print(s) end end -- Return true only when the fully expanded TeX argument is exactly one -- lowercase ASCII letter. TeX performs the expansion; Lua deliberately keeps -- the classification small and predictable (not locale dependent). function KKS.is_lowercase_ascii(s) return s:match("^[a-z]$") ~= nil end function KKS.lowercase_ascii_flag(s) return KKS.is_lowercase_ascii(s) and 1 or 0 end -- Actual glyph-ink bounds for the shrink-only visual overflow guard. -- -- Use LuaTeX's documented fontloader API instead of luaotfload's internal -- shared.rawdata tables. The latter can differ between cache states and TeX -- Live releases. Cache the scaled result by font id and glyph index so a font -- file is normally opened only once for each distinct glyph used by a guard. local visual_bbox_cache = {} local glyph_id = node.id("glyph") local hlist_id = node.id("hlist") local vlist_id = node.id("vlist") local rule_id = node.id("rule") local glue_id = node.id("glue") local kern_id = node.id("kern") local disc_id = node.id("disc") local penalty_id = node.id("penalty") local dir_id = node.id("dir") local whatsit_id = node.id("whatsit") local colorstack_subtype = node.subtype("pdf_colorstack") local userdefined_subtype = node.subtype("user_defined") local pdfsave_subtype = node.subtype("pdf_save") local pdfrestore_subtype = node.subtype("pdf_restore") local running_rule = -1073741824 local function fontloader_field(object, key) local ok, value = pcall(function() return object[key] end) return ok and value or nil end local function normalized_font_name(name) if type(name) ~= "string" then return nil end return name:lower():gsub("[^%w]", "") end local function compatible_font_name(a, b) a, b = normalized_font_name(a), normalized_font_name(b) if not a or not b then return false end if a == b then return true end -- macOS Hiragino Pro/ProN collection aliases select the same family/weight -- under names that differ only by the JIS2004 "N" marker. The glyph-level -- Unicode and advance checks below still have to succeed. return a:gsub("pron", "pro") == b:gsub("pron", "pro") end local function loaded_face_matches(loaded, expected_names) local actual_names = { fontloader_field(loaded, "fontname"), fontloader_field(loaded, "fullname"), fontloader_field(loaded, "familyname"), } for i = 1, #actual_names do for j = 1, #expected_names do if compatible_font_name(actual_names[i], expected_names[j]) then return true end end end return false end local function open_font_file(tfm) if not tfm or not tfm.filename then return nil end local filename = tfm.filename:lower() local is_collection = filename:match("%.ttc$") or filename:match("%.otc$") or filename:match("%.dfont$") if not is_collection then local ok, loaded = pcall(fontloader.open, tfm.filename) if ok and loaded then return loaded end return nil end local names = {} local seen = {} local function add_name(name) if name and name ~= "" and not seen[name] then seen[name] = true names[#names + 1] = name end end -- Prefer the collection's official face name corresponding to luaotfload's -- numeric subfont index, then try the names stored in the live font table. local info_ok, info = pcall(fontloader.info, tfm.filename) if info_ok and type(info) == "table" and type(tfm.subfont) == "number" then local face = info[tfm.subfont + 1] or info[tfm.subfont] if type(face) == "table" then add_name(face.fontname) add_name(face.fullname) end end add_name(tfm.fontname) add_name(tfm.psname) add_name(tfm.fullname) for _, name in ipairs(names) do local ok, loaded = pcall(fontloader.open, tfm.filename, name) if ok and loaded then if loaded_face_matches(loaded, names) then return loaded end pcall(fontloader.close, loaded) end end -- A collection needs an explicit face name. Falling back to its first face -- can silently measure a different font from the one LuaTeX is rendering. return nil end local function glyph_ink_bbox(font_id, char) local tfm = font.getfont(font_id) if not tfm or tfm.type == "virtual" or not tfm.characters then return nil end -- Font expansion and slant are affine and can be reflected in the box. -- Synthetic emboldening changes the outline itself, so fail open there. if tfm.embolden and tfm.embolden ~= 0 then return nil end local character = tfm.characters[char] local index = character and character.index if index == nil then return nil end local font_cache = visual_bbox_cache[font_id] if not font_cache then font_cache = {} visual_bbox_cache[font_id] = font_cache end local cached = font_cache[index] if cached ~= nil then return cached or nil end local loaded = open_font_file(tfm) if not loaded then font_cache[index] = false return nil end local debug_reason local ok, result = pcall(function() local glyph_source = loaded if index < loaded.glyphmin or index > loaded.glyphmax then glyph_source = nil if loaded.subfonts then for i = 1, #loaded.subfonts do local subfont = loaded.subfonts[i] if index >= subfont.glyphmin and index <= subfont.glyphmax then local glyph_ok, candidate = pcall(function() return subfont.glyphs[index] end) if glyph_ok and candidate then glyph_source = subfont break end end end end if not glyph_source then debug_reason = string.format("index=%s range=[%s,%s] subfonts=%s", tostring(index), tostring(loaded.glyphmin), tostring(loaded.glyphmax), tostring(loaded.subfonts ~= nil)) return nil end end local glyph = glyph_source.glyphs and glyph_source.glyphs[index] local bbox = glyph and glyph.boundingbox local units = glyph_source.units_per_em if not units or units == 0 then units = loaded.units_per_em end if not units or units == 0 then units = tfm.units_per_em end if not bbox or not units or units == 0 then debug_reason = string.format("glyph=%s bbox=%s units=%s range=[%s,%s] subfonts=%s", tostring(glyph ~= nil), tostring(bbox ~= nil), tostring(units), tostring(glyph_source.glyphmin), tostring(glyph_source.glyphmax), tostring(loaded.subfonts ~= nil)) return nil end local scale = tfm.size / units local extend = (tfm.extend or 1000) / 1000 local slant = (tfm.slant or 0) / 1000 -- A CID-keyed CFF can expose subfont glyphs by CID while LuaTeX's live -- character table stores a glyph index. Accept the lookup only when the -- record identifies the same Unicode character and scaled advance. Any -- ambiguity must preserve the legacy rendering (fail open), never shrink -- from a plausible but unrelated glyph box. local glyph_unicode = fontloader_field(glyph, "unicode") if type(glyph_unicode) == "number" and glyph_unicode >= 0 and glyph_unicode ~= char then debug_reason = string.format("glyph unicode mismatch U+%04X != U+%04X", glyph_unicode, char) return nil end local glyph_width = fontloader_field(glyph, "width") if type(glyph_width) == "number" and type(character.width) == "number" then local loaded_width = glyph_width * scale * extend local tolerance = math.max(2, math.abs(character.width) * 0.02) if math.abs(loaded_width - character.width) > tolerance then debug_reason = string.format("glyph width mismatch %.2f != %.2f", loaded_width, character.width) return nil end end local xmin, ymin = math.huge, math.huge local xmax, ymax = -math.huge, -math.huge for _, x in ipairs({ bbox[1], bbox[3] }) do for _, y in ipairs({ bbox[2], bbox[4] }) do local tx = (extend * x + slant * y) * scale local ty = y * scale xmin, xmax = math.min(xmin, tx), math.max(xmax, tx) ymin, ymax = math.min(ymin, ty), math.max(ymax, ty) end end return { xmin, ymin, xmax, ymax } end) pcall(fontloader.close, loaded) if not ok or not result then if KKS.visual_debug then texio.write_nl("term and log", "KKS-VISUAL fontloader failure " .. tostring(debug_reason or result)) end font_cache[index] = false return nil end font_cache[index] = result return result end local function include_bbox(bounds, xmin, ymin, xmax, ymax) bounds.xmin = math.min(bounds.xmin, xmin) bounds.ymin = math.min(bounds.ymin, ymin) bounds.xmax = math.max(bounds.xmax, xmax) bounds.ymax = math.max(bounds.ymax, ymax) bounds.found = true end local function effective_glue_width(n, parent) local width = n.width or 0 if not parent then return width end local sign = parent.glue_sign or 0 local order = parent.glue_order or 0 local set = parent.glue_set or 0 if sign == 1 and (n.stretch_order or 0) == order then width = width + set * (n.stretch or 0) elseif sign == 2 and (n.shrink_order or 0) == order then width = width - set * (n.shrink or 0) end return width end local measure_hlist local function measure_vlist(box, x0, y0, bounds) -- Star-path alphanumerics are horizontal material. Empty nested vlists -- (notably phantoms) are harmless, but arbitrary vertical material needs a -- full page-builder model and therefore fails open deterministically. if not box.head then return true end for n in node.traverse(box.head) do if n.id == glue_id or n.id == kern_id or n.id == penalty_id or n.id == dir_id then -- dimension-only / non-ink nodes elseif n.id == hlist_id and not n.head then -- empty phantom box elseif n.id == rule_id and (n.width or 0) == 0 then -- zero-width phantom rule else return false end end return true end measure_hlist = function(box, x0, y0, bounds) local x = x0 if not box.head then return true end for n in node.traverse(box.head) do if n.id == glyph_id then local bbox = glyph_ink_bbox(n.font, n.char) if not bbox then if KKS.visual_debug then local tfm = font.getfont(n.font) texio.write_nl("term and log", string.format( "KKS-VISUAL missing glyph bbox font=%s char=U+%04X file=%s name=%s type=%s index=%s", tostring(n.font), n.char, tostring(tfm and tfm.filename), tostring(tfm and tfm.fontname), tostring(tfm and tfm.type), tostring(tfm and tfm.characters and tfm.characters[n.char] and tfm.characters[n.char].index))) end return false end local xoffset = n.xoffset or 0 local yoffset = n.yoffset or 0 include_bbox(bounds, x + xoffset + bbox[1], y0 + yoffset + bbox[2], x + xoffset + bbox[3], y0 + yoffset + bbox[4]) x = x + (n.width or 0) elseif n.id == kern_id then x = x + (n.kern or 0) elseif n.id == glue_id then if n.leader then return false end x = x + effective_glue_width(n, box) elseif n.id == hlist_id then if not measure_hlist(n, x, y0 - (n.shift or 0), bounds) then return false end x = x + (n.width or 0) elseif n.id == vlist_id then if not measure_vlist(n, x, y0 - (n.shift or 0), bounds) then return false end x = x + (n.width or 0) elseif n.id == rule_id then local width = n.width or 0 if width == running_rule or (n.height or 0) == running_rule or (n.depth or 0) == running_rule then return false end if width > 0 and ((n.height or 0) > 0 or (n.depth or 0) > 0) then include_bbox(bounds, x, y0 - (n.depth or 0), x + width, y0 + (n.height or 0)) end x = x + width elseif n.id == disc_id then if n.replace then local replacement = node.hpack(node.copy_list(n.replace)) local ok = measure_hlist(replacement, x, y0, bounds) local width = replacement.width or 0 node.flush_node(replacement) if not ok then return false end x = x + width end elseif n.id == penalty_id or n.id == dir_id then -- no ink and no horizontal advance elseif n.id == whatsit_id then -- Colour and luatexja bookkeeping do not draw ink. Literal PDF, -- images, forms, links, writes, etc. are deliberately unsupported. if n.subtype ~= colorstack_subtype and n.subtype ~= userdefined_subtype and n.subtype ~= pdfsave_subtype and n.subtype ~= pdfrestore_subtype then if KKS.visual_debug then texio.write_nl("term and log", string.format( "KKS-VISUAL unsupported whatsit subtype=%s", tostring(n.subtype))) end return false end else if KKS.visual_debug then texio.write_nl("term and log", string.format( "KKS-VISUAL unsupported node type=%s subtype=%s", tostring(node.type(n.id)), tostring(n.subtype))) end return false end end return true end local function box_ink_bounds(box_number) local box = tex.getbox(box_number) if not box or box.id ~= hlist_id then return nil end local bounds = { xmin = math.huge, ymin = math.huge, xmax = -math.huge, ymax = -math.huge, found = false, } if not measure_hlist(box, 0, 0, bounds) or not bounds.found then return nil end bounds.width = box.width or 0 bounds.height = box.height or 0 bounds.depth = box.depth or 0 return bounds end -- How many glyphs the measured material really contains. The star routes ask -- this to tell a single letter, whose width the em bounds and whose ink never -- reaches its own box corners, from a string, whose width nothing bounds. It -- counts what was actually typeset, so it is unaffected by expansion, by the -- font's ligatures, and by any grouping in the argument. local function count_glyphs(head) local total = 0 local item = head while item do if item.id == glyph_id then total = total + 1 elseif item.id == hlist_id or item.id == vlist_id then total = total + count_glyphs(item.list) end item = item.next end return total end function KKS.glyph_count(box_number) local ok, total = pcall(function() local box = tex.getbox(box_number) if not box then return 0 end return count_glyphs(box.list) end) if not ok or not total then return 0 end return total end -- Return an empty string for the byte-identical pass/fail-open path. Otherwise -- return only an additional, uniform shrink factor for the already existing -- star-path scaler. Ink is never used as a sizing or centering basis. function KKS.visual_scale(raw_box_number, legacy_box_number, shape, p1, p2) local ok, factor = pcall(function() local raw = box_ink_bounds(raw_box_number) local legacy_box = tex.getbox(legacy_box_number) local raw_box = tex.getbox(raw_box_number) if not raw or not legacy_box or not raw_box then return nil end local legacy_scale if (raw_box.width or 0) ~= 0 then legacy_scale = (legacy_box.width or 0) / raw_box.width else local raw_total = (raw_box.height or 0) + (raw_box.depth or 0) if raw_total == 0 then return nil end legacy_scale = ((legacy_box.height or 0) + (legacy_box.depth or 0)) / raw_total end if not legacy_scale or legacy_scale <= 0 or legacy_scale ~= legacy_scale or legacy_scale == math.huge then return nil end local cx = (raw.width or 0) / 2 local cy = ((raw.height or 0) - (raw.depth or 0)) / 2 local xs = { (raw.xmin - cx) * legacy_scale, (raw.xmax - cx) * legacy_scale, } local ys = { (raw.ymin - cy) * legacy_scale, (raw.ymax - cy) * legacy_scale, } local required = 1 if shape == "circle" then local radius = tonumber(p1) if not radius or radius <= 0 then return nil end -- A fontloader bounding box is the smallest axis-aligned ink box, not -- the glyph outline. Testing its empty corners against a circle creates -- false positives (notably the documented i/iv/viii samples). Its four -- extrema, however, prove overflow whenever they cross the visible -- circle's horizontal or vertical extent. local max_x = math.max(math.abs(xs[1]), math.abs(xs[2])) local max_y = math.max(math.abs(ys[1]), math.abs(ys[2])) required = math.min(1, radius / max_x, radius / max_y) elseif shape == "rectangle" then local half_width, half_height = tonumber(p1), tonumber(p2) if not half_width or not half_height or half_width <= 0 or half_height <= 0 then return nil end local max_x = math.max(math.abs(xs[1]), math.abs(xs[2])) local max_y = math.max(math.abs(ys[1]), math.abs(ys[2])) required = math.min(1, half_width / max_x, half_height / max_y) else return nil end if required >= 0.999999 then if KKS.visual_debug then texio.write_nl("term and log", string.format( "KKS-VISUAL shape=%s scale=pass legacy=%.8f x=[%.2f,%.2f] y=[%.2f,%.2f] p=[%.2f,%.2f]", shape, legacy_scale, xs[1], xs[2], ys[1], ys[2], tonumber(p1) or 0, tonumber(p2) or 0)) end return nil end if required < 0.0000001 or required ~= required or required == math.huge then return nil end if KKS.visual_debug then texio.write_nl("term and log", string.format( "KKS-VISUAL shape=%s scale=%.8f legacy=%.8f x=[%.2f,%.2f] y=[%.2f,%.2f] p=[%.2f,%.2f]", shape, required, legacy_scale, xs[1], xs[2], ys[1], ys[2], tonumber(p1) or 0, tonumber(p2) or 0)) end return required end) if not ok or not factor then return "" end return string.format("%.8f", factor) end -- Return an additional uniform shrink factor that keeps the already-scaled -- content between the actual inner ink edges of a pair of bracket glyphs. -- The caller passes the unscaled bracket boxes because graphicx implements -- horizontal scaling with PDF transformation whatsits; applying the known -- x-scale here keeps the measurement on documented fontloader data. function KKS.visual_bracket_scale(raw_box_number, legacy_box_number, left_box_number, right_box_number, inner_width, overlap, bracket_xscale, angle) local ok, factor = pcall(function() if KKS.visual_debug then texio.write_nl("term and log", string.format( "KKS-BRACKET boxes legacy=%s left=%s right=%s", tostring(legacy_box_number), tostring(left_box_number), tostring(right_box_number))) end local content = box_ink_bounds(raw_box_number) local raw_box = tex.getbox(raw_box_number) local legacy_box = tex.getbox(legacy_box_number) local left = box_ink_bounds(left_box_number) local right = box_ink_bounds(right_box_number) inner_width = tonumber(inner_width) overlap = tonumber(overlap) bracket_xscale = tonumber(bracket_xscale) angle = tonumber(angle) if not content or not raw_box or not legacy_box or not left or not right or not inner_width or not overlap or not bracket_xscale or not angle or inner_width <= 0 or bracket_xscale <= 0 then if KKS.visual_debug then texio.write_nl("term and log", string.format( "KKS-BRACKET fail inputs content=%s left=%s right=%s width=%s overlap=%s xscale=%s angle=%s", tostring(content ~= nil), tostring(left ~= nil), tostring(right ~= nil), tostring(inner_width), tostring(overlap), tostring(bracket_xscale), tostring(angle))) end return nil end local middle_start = bracket_xscale * left.width - overlap local middle_center = middle_start + inner_width / 2 local left_limit = bracket_xscale * left.xmax - middle_center local right_start = middle_start + inner_width - overlap local right_limit = right_start + bracket_xscale * right.xmin - middle_center -- A glyph bbox gives only the global x-extreme. For curved, angled, and -- corner brackets that extreme may occur at a tip far above or below the -- content and is not evidence of an ink collision. The middle box edges -- are the strongest limits we can prove from bbox data without inventing -- an outline intersection. This deliberately favours the exact legacy -- rendering unless content ink actually leaves the designated opening. left_limit = math.min(left_limit, -inner_width / 2) right_limit = math.max(right_limit, inner_width / 2) if left_limit >= 0 or right_limit <= 0 then if KKS.visual_debug then texio.write_nl("term and log", string.format( "KKS-BRACKET fail gap limits=[%.2f,%.2f]", left_limit, right_limit)) end return nil end local legacy_scale if raw_box.width ~= 0 then legacy_scale = legacy_box.width / raw_box.width else local raw_total = raw_box.height + raw_box.depth if raw_total == 0 then return nil end legacy_scale = (legacy_box.height + legacy_box.depth) / raw_total end if legacy_scale <= 0 or legacy_scale ~= legacy_scale or legacy_scale == math.huge then return nil end local cx = content.width / 2 local cy = (content.height - content.depth) / 2 local radians = angle * math.pi / 180 local cosine, sine = math.cos(radians), math.sin(radians) local xmin, xmax = math.huge, -math.huge for _, x in ipairs({ (content.xmin - cx) * legacy_scale, (content.xmax - cx) * legacy_scale }) do for _, y in ipairs({ (content.ymin - cy) * legacy_scale, (content.ymax - cy) * legacy_scale }) do local rotated_x = x * cosine - y * sine xmin, xmax = math.min(xmin, rotated_x), math.max(xmax, rotated_x) end end local required = 1 if xmin < left_limit then required = math.min(required, (-left_limit) / (-xmin)) end if xmax > right_limit then required = math.min(required, right_limit / xmax) end if required >= 0.999999 then if KKS.visual_debug then texio.write_nl("term and log", string.format( "KKS-BRACKET scale=pass x=[%.2f,%.2f] limits=[%.2f,%.2f] angle=%.2f", xmin, xmax, left_limit, right_limit, angle)) end return nil end if required < 0.0000001 or required ~= required or required == math.huge then return nil end if KKS.visual_debug then texio.write_nl("term and log", string.format( "KKS-BRACKET scale=%.8f x=[%.2f,%.2f] limits=[%.2f,%.2f] angle=%.2f", required, xmin, xmax, left_limit, right_limit, angle)) end return required end) if not ok or not factor then return "" end return string.format("%.8f", factor) end _G.KKsymbols = KKS