-- luatexko.lua
--
-- Copyright (c) 2013-2026 Dohyun Kim <nomosnomos at gmail com>
--                         Soojin Nam <jsunam at gmail com>
--
-- 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 2006/05/20 or later.

luatexbase.provides_module {
  name        = 'luatexko',
  date        = '2026/08/28',
  version     = '5.8',
  description = 'typesetting Korean with LuaTeX',
  author      = 'Dohyun Kim, Soojin Nam',
  license     = 'LPPL v1.3+',
}

luatexko = luatexko or {}
local luatexko = luatexko

local dimensions      = node.dimensions
local end_of_math     = node.end_of_math
local getglue         = node.getglue
local getnext         = node.getnext
local getprev         = node.getprev
local getproperty     = node.getproperty
local has_attribute   = node.has_attribute
local has_glyph       = node.has_glyph
local insert_after    = node.insert_after
local insert_before   = node.insert_before
local nodecopy        = node.copy
local nodefree        = node.free
local nodenew         = node.new
local noderemove      = node.remove
local nodeslide       = node.slide
local nodewrite       = node.write
local rangedimensions = node.rangedimensions
local set_attribute   = node.set_attribute
local setglue         = node.setglue
local setproperty     = node.setproperty
local unset_attribute = node.unset_attribute

local fontgetfont   = font.getfont
local getparameters = font.getparameters

local function warning (fmt, ...)
  return luatexbase.module_warning("luatexko", fmt:format(...))
end

local dirid     = node.id"dir"
local discid    = node.id"disc"
local glueid    = node.id"glue"
local glyphid   = node.id"glyph"
local hlistid   = node.id"hlist"
local ins_id    = node.id"ins"
local kernid    = node.id"kern"
local localparid = node.id"local_par"
local mathid    = node.id"math"
local penaltyid = node.id"penalty"
local ruleid    = node.id"rule"
local vlistid   = node.id"vlist"
local whatsitid = node.id"whatsit"
local literal_whatsit = node.subtype"pdf_literal"
local save_whatsit    = node.subtype"pdf_save"
local restore_whatsit = node.subtype"pdf_restore"
local matrix_whatsit  = node.subtype"pdf_setmatrix"
local nohyphen  = luatexbase.registernumber"l@nohyphenation" or -1 -- verbatim
local langkor   = luatexbase.registernumber"koreanlanguage"  or 16383

local hangulfontattr   = luatexbase.attributes.luatexkohangulfontattr
local hanjafontattr    = luatexbase.attributes.luatexkohanjafontattr
local fallbackfontattr = luatexbase.attributes.luatexkofallbackfontattr
local autojosaattr     = luatexbase.attributes.luatexkoautojosaattr
local classicattr      = luatexbase.attributes.luatexkoclassicattr
local dotemphattr      = luatexbase.attributes.luatexkodotemphattr
local rubyattr         = luatexbase.attributes.luatexkorubyattr
local hangulbyhangulattr = luatexbase.attributes.luatexkohangulbyhangulattr
local hanjabyhanjaattr   = luatexbase.attributes.luatexkohanjabyhanjaattr
local inhibitglueattr  = luatexbase.new_attribute"luatexko_inhibitglue_attr"
local unicodeattr      = luatexbase.new_attribute"luatexko_unicode_attr"
local charhead         = luatexbase.new_attribute"luatexko_char_head_attr"
local chartail         = luatexbase.new_attribute"luatexko_char_tail_attr"
local verticalattr  -- set later at otfregister
local charraiseattr -- set later at otfregister

local stretch_f = 5/100 -- should be consistent for ruby

-- font.getfont() knows only the fonts that Lua defined, so for the fonts TeX
-- itself loaded -- every tfm font of plain TeX -- we have to fall back on
-- font.fonts, whose __index rebuilds the entire font table from TeX's font
-- memory on each and every access.  That costs some 90 microseconds a time,
-- which is ruinous in the per-glyph loops below (char_in_font() alone asks
-- for it once per character of the document).  A font's table never changes
-- after the font is defined, so we keep what we built.  Fonts that Lua did
-- define are cached by font.getfont() already; caching them here too costs
-- nothing and keeps the code in one shape.
local font_data_cache = {}

local function get_font_data (fontid)
  local fontdata = font_data_cache[fontid]
  if fontdata == nil then
    fontdata = fontgetfont(fontid) or font.fonts[fontid]
    if fontdata == nil then return {} end -- undefined font; do not cache
    font_data_cache[fontid] = fontdata
  end
  return fontdata
end

local function get_font_param (f, key)
  local t
  if type(f) == "number" then
    t = getparameters(f)
    if t and t[key] then
      return t[key]
    end
    f = get_font_data(f)
  end
  if type(f) == "table" then
    t = f.parameters
    return t and t[key]
  end
end

local function option_in_font (fontdata, optionname)
  if type(fontdata) == "number" then
    fontdata = get_font_data(fontdata)
  end
  if fontdata.shared then
    return fontdata.shared.features[optionname]
  end
end

local function font_opt_dim (fd, optname)
  local dim = option_in_font(fd, optname)
  if dim then
    local m, u
    if type(dim) == "string" then
      m, u = dim:match"^(.+)(e[mx])%s*$"
    end
    if m and u then
      local params = type(fd) == "number" and getparameters(fd) or fd.parameters
      if params then
        dim = u == "em" and m * params.quad or m * params.x_height
      end
    else
      dim = tex.sp(dim)
    end
    return dim
  end
end

local function has_harf_data (f)
  if type(f) == "number" then
    f = get_font_data(f)
  end
  return f.hb
end

local harfbuzz = luaotfload.harfbuzz
local get_asc_desc
if harfbuzz then
  local os2tag = harfbuzz.Tag.new"OS/2"
  function get_asc_desc (hb) -- fontdata.hb
    if hb and os2tag then
      local hbface = hb.shared.face
      local os2 = hbface:get_table(os2tag)
      if os2:get_length() > 69 then -- sTypoAscender (int16)
        local data = os2:get_data()
        local typoascender  = string.unpack(">h", data, 69)
        local typodescender = string.unpack(">h", data, 71)
        return typoascender * hb.scale, -typodescender * hb.scale
      end
    end
  end
end

-- Each of these tables memoizes a per-font-id value: the __index metamethod
-- computes the value once, caches it under the font id, and returns it.
-- The pattern was identical for a dozen options, so we build them from a
-- single factory. `compute` is called only with a truthy fid; when fid is
-- falsy the metamethod yields `default` (nil unless given).
local function memo_per_font (compute, default)
  return setmetatable( {}, { __index = function(t, fid)
    if not fid then return default end
    local v = compute(fid)
    t[fid] = v
    return v
  end } )
end

local fontoptions = {
  is_widefont = memo_per_font(function(fid)
    local fontdata = get_font_data(fid)
    local format   = fontdata.format
    local encode   = fontdata.encodingbytes
    return encode == 2 or format == "opentype" or format == "truetype"
  end),

  is_hangulscript = memo_per_font(function(fid)
    return option_in_font(fid, "script") == "hang"
  end),

  compresspunctuations = memo_per_font(function(fid)
    return option_in_font(fid, "compresspunctuations")
       and not option_in_font(fid, "halt")
       and not option_in_font(fid, "vhal")
       or false
  end),

  removeclassicspaces = memo_per_font(function(fid)
    return option_in_font(fid, "removeclassicspaces") or false
  end),

  slantvalue = memo_per_font(function(fid)
    return option_in_font(fid, "slant") or false
  end),

  charraise = memo_per_font(function(fid)
    return font_opt_dim(fid, "charraise") or false
  end),

  intercharacter = memo_per_font(function(fid)
    return font_opt_dim(fid, "intercharacter") or false
  end),

  intercharstretch = memo_per_font(function(fid)
    return font_opt_dim(fid, "intercharstretch") or false
  end),

  intercharpenalty = memo_per_font(function(fid)
    return option_in_font(fid, "intercharpenalty") or false
  end),

  interhangul = memo_per_font(function(fid)
    return font_opt_dim(fid, "interhangul") or false
  end),

  interlatincjk = memo_per_font(function(fid)
    return font_opt_dim(fid, "interlatincjk") or false
  end),

  is_vertical = memo_per_font(function(fid)
    return option_in_font(fid, "vertical") or false
  end),

  en_size = memo_per_font(function(fid)
    return (get_font_param(fid, "quad") or 655360)/2
  end, 327680),

  hangulspaceskip = memo_per_font(function(fid)
    local newwd
    if has_harf_data(fid) then
      newwd = getparameters(fid) or false
      if newwd then
        newwd = { newwd.space, newwd.space_stretch, newwd.space_shrink, newwd.extra_space }
      end
    else
      local newsp = nodenew(glyphid)
      newsp.char, newsp.font = 32, fid
      newsp = nodes.simple_font_handler(newsp) -- incorrect in vertical writing. backward compat.
      newwd = newsp and newsp.width or false
      if newwd then
        newwd = { tex.sp(newwd), tex.sp(newwd/2), tex.sp(newwd/3), tex.sp(newwd/3) }
      end
      if newsp then nodefree(newsp) end
    end
    return newwd
  end),

  monospaced = memo_per_font(function(fid)
    -- space_stretch has been set to zero by fontloader
    if get_font_param(fid, "space_stretch") == 0 then
      return true
    end
    -- but not in harf mode; so we simply test widths of some glyphs
    local chars = get_font_data(fid).characters or {}
    local i, M = chars[0x69], chars[0x4D]
    return i and M and i.width == M.width or false
  end),

  asc_desc = memo_per_font(function(fid)
    local asc, desc = get_font_param(fid, "ascender"), get_font_param(fid, "descender")
    -- luaharfbuzz's Font:get_h_extents() gets ascender value from hhea table;
    -- Node mode's parameters.ascender is gotten from OS/2 table.
    -- TypoAscender in OS/2 table seems to be more suitable for our purpose.
    if not (asc and desc) then
      asc, desc = get_asc_desc(has_harf_data(fid))
    end
    return { asc or false, desc or false }
  end, { }),

  vertcharraise = memo_per_font(function(fid)
    local fontdata = get_font_data(fid)
    return fontdata and fontdata.vertcharraise or false
  end),

  hb_char_bbox = { }, -- for vertical writing or fake slant
  tsb_data = { }, -- for vertical writing
}

local function char_in_font(fontdata, char)
  if type(fontdata) == "number" then
    fontdata = get_font_data(fontdata)
  end
  if fontdata.characters then
    return fontdata.characters[char]
  end
end

local function harf_actual_literal (curr)
  if curr.id == whatsitid and curr.subtype == literal_whatsit then
    local data = curr.data
    return data == "EMC" and 2 or data and data:find"BDC$" and 1 or false
  end
end

local function is_hanja (c)
  return c >= 0x3400 and c <= 0xA4C6
  or     c >= 0xF900 and c <= 0xFAFF
  or     c >= 0xFF10 and c <= 0xFF19
  or     c >= 0xFF21 and c <= 0xFF3A
  or     c >= 0xFF41 and c <= 0xFF5A
  or     c >= 0x20000 and c <= 0x3FFFD
  or     c >= 0x2E81 and c <= 0x2FD5
end

local function is_hangul (c)
  return c >= 0xAC00 and c <= 0xD7A3
end

local function is_chosong (c)
  return c >= 0x1100 and c <= 0x115F
  or     c >= 0xA960 and c <= 0xA97C
end

local function is_jungsong (c)
  return c >= 0x1160 and c <= 0x11A7
  or     c >= 0xD7B0 and c <= 0xD7C6
end

local function is_jongsong (c)
  return c >= 0x11A8 and c <= 0x11FF
  or     c >= 0xD7CB and c <= 0xD7FB
end

local hangul_tonemark = {
  [0x302E] = true, [0x302F] = true,
}

local function is_compat_jamo (c)
  return c >= 0x3131 and c <= 0x318E
end

local function is_combining (c)
  return is_jungsong(c)
  or     is_jongsong(c)
  or     c >= 0x302A and c <= 0x302F
  or     c == 0x3099 or  c == 0x309A or c == 0x3035
  -- variation selectors
  or     c >= 0xFE00  and c <= 0xFE0F
  or     c >= 0xE0100 and c <= 0xE01EF
  -- others (probably non-cjk)
  or     c >= 0x0300 and c <= 0x036F
  or     c >= 0x1AB0 and c <= 0x1AFF
  or     c >= 0x1DC0 and c <= 0x1DFF
  or     c >= 0x20D0 and c <= 0x20FF
  or     c >= 0xFE20 and c <= 0xFE2F
end

local function is_noncjk_char (c)
  return c >= 0x30 and c <= 0x39
  or     c >= 0x41 and c <= 0x5A
  or     c >= 0x61 and c <= 0x7A
  or     c >= 0xC0 and c <= 0xD6
  or     c >= 0xD8 and c <= 0xF6
  or     c >= 0xF8 and c <= 0x10FF
  or     c >= 0x1200 and c <= 0x1FFF
  or     c >= 0xA4D0 and c <= 0xA95F
  or     c >= 0xA980 and c <= 0xABFF
  or     c >= 0xFB00 and c <= 0xFDFF
  or     c >= 0xFE70 and c <= 0xFEFF
end

local function is_kana (c)
  return c >= 0x3041 and c <= 0x3096
  or     c >= 0x30A1 and c <= 0x30FA
  or     c >= 0x31F0 and c <= 0x31FF
  or     c >= 0xFF66 and c <= 0xFF6F
  or     c >= 0xFF71 and c <= 0xFF9D
  or     c == 0x309F or c == 0x30FF
  or     c >= 0x1B000 and c <= 0x1B16F
end

local function is_hangul_jamo (c)
  return is_hangul(c)
  or     is_compat_jamo(c)
  or     is_chosong(c)
  or     is_jungsong(c)
  or     is_jongsong(c)
  or     hangul_tonemark[c]
end

local intercharclass = { [0] =
  { [0] = nil,    {1,1},  nil,    {.5,.5} },
  { [0] = nil,    nil,    nil,    {.5,.5} }, -- openers
  { [0] = {1,1},  {1,1},  nil,    {.5,.5}, nil,    {1,1},  {1,1} }, -- closers
  { [0] = {.5,.5},{.5,.5},{.5,.5},{1,.5},  {.5,.5},{.5,.5},{.5,.5},{.5,.5} }, -- middle dots
  { [0] = {1,0},  {1,0},  nil,    {1.5,.5},nil,    {1,0},  {1,0} }, -- full stops
  { [0] = nil,    {1,1},  nil,    {.5,.5} }, -- leaders and ellipses
  { [0] = {1,1},  {1,1},  nil,    {.5,.5} }, -- questions and exclamations
  { [0] = {.5,.5},{.5,.5},nil,    {.5,.5} }, -- vertical colons
}

local charclass = setmetatable({
  [0x2018] = 1, -- ‘
  [0x201C] = 1, -- “
  [0x2329] = 1, -- 〈
  [0x3008] = 1, -- 〈
  [0x300A] = 1, -- 《
  [0x300C] = 1, -- 「
  [0x300E] = 1, -- 『
  [0x3010] = 1, -- 【
  [0x3014] = 1, -- 〔
  [0x3016] = 1, -- 〖
  [0x3018] = 1, -- 〘
  [0x301A] = 1, -- 〚
  [0x301D] = 1, -- 〝 REVERSED DOUBLE PRIME QUOTATION MARK
  [0xFE17] = 1, -- ︗
  [0xFE35] = 1, -- ︵
  [0xFE37] = 1, -- ︷
  [0xFE39] = 1, -- ︹
  [0xFE3B] = 1, -- ︻
  [0xFE3D] = 1, -- ︽
  [0xFE3F] = 1, -- ︿
  [0xFE41] = 1, -- ﹁
  [0xFE43] = 1, -- ﹃
  [0xFE47] = 1, -- ﹇
  [0xFF08] = 1, -- （
  [0xFF3B] = 1, -- ［
  [0xFF5B] = 1, -- ｛
  [0xFF5F] = 1, -- ｟
  --
  [0x2019] = 2, -- ’
  [0x201D] = 2, -- ”
  [0x232A] = 2, -- 〉
  [0x3001] = 2, -- 、
  [0x3009] = 2, -- 〉
  [0x300B] = 2, -- 》
  [0x300D] = 2, -- 」
  [0x300F] = 2, -- 』
  [0x3011] = 2, -- 】
  [0x3015] = 2, -- 〕
  [0x3017] = 2, -- 〗
  [0x3019] = 2, -- 〙
  [0x301B] = 2, -- 〛
  [0x301E] = 2, -- 〞 DOUBLE PRIME QUOTATION MARK
  [0x301F] = 2, -- 〟 LOW DOUBLE PRIME QUOTATION MARK
  [0xFE10] = 2, -- ︐
  [0xFE11] = 2, -- ︑
  [0xFE18] = 2, -- ︘
  [0xFE36] = 2, -- ︶
  [0xFE38] = 2, -- ︸
  [0xFE3A] = 2, -- ︺
  [0xFE3C] = 2, -- ︼
  [0xFE3E] = 2, -- ︾
  [0xFE40] = 2, -- ﹀
  [0xFE42] = 2, -- ﹂
  [0xFE44] = 2, -- ﹄
  [0xFE48] = 2, -- ﹈
  [0xFF09] = 2, -- ）
  [0xFF0C] = 2, -- ，
  [0xFF3D] = 2, -- ］
  [0xFF5D] = 2, -- ｝
  [0xFF60] = 2, -- ｠
  --
  [0x00B7] = 3, -- ·
  [0x30FB] = 3, -- ・
  [0xFF1A] = 3, -- ：
  [0xFF1B] = 3, -- ；
  --
  [0x3002] = 4, -- 。
  [0xFE12] = 4, -- ︒
  [0xFF0E] = 4, -- ．
  --
  [0x2015] = 5, -- ―
  [0x2025] = 5, -- ‥
  [0x2026] = 5, -- …
  [0xFE19] = 5, -- ︙
  [0xFE30] = 5, -- ︰
  [0xFE31] = 5, -- ︱
  --
  [0xFE15] = 6, -- ︕
  [0xFE16] = 6, -- ︖
  [0xFF01] = 6, -- ！
  [0xFF1F] = 6, -- ？
}, { __index = function() return 0 end })

local get_char_class
do
  local special_classes = {
    [0] = charclass,
    setmetatable({  -- vert
      [0xFF1A] = 7, [0xFF1B] = 7,  -- 0xFE13, 0xFE14
    }, { __index = charclass }),
    setmetatable({  -- SC
      [0xFF01] = 4, [0xFF1A] = 2, [0xFF1B] = 2, [0xFF1F] = 4,
    }, { __index = charclass }),
    setmetatable({  -- TC
      [0x3001] = 3, [0x3002] = 3, [0xFF0C] = 3, [0xFF0E] = 3,
    }, { __index = charclass }),
    setmetatable({  -- TC vert
      [0x3001] = 3, [0x3002] = 3, [0xFF0C] = 3, [0xFF0E] = 3,
      [0xFF1A] = 7, [0xFF1B] = 7,  -- 0xFE13, 0xFE14
    }, { __index = charclass }),
    setmetatable({  -- JP vert
      [0xFF1B] = 7, -- 0xFE14
    }, { __index = charclass }),
  }

  function get_char_class (c, classic)
    return special_classes[classic or 0][c]
  end
end

local breakable_after = setmetatable({
  [0x21] = true, -- !
  [0x22] = true, -- "
  [0x25] = true, -- %
  [0x27] = true, -- '
  [0x29] = true, -- )
  [0x2C] = true, -- ,
  [0x2D] = true, -- -
  [0x2E] = true, -- .
  [0x3A] = true, -- :
  [0x3B] = true, -- ;
  [0x3E] = true, -- >
  [0x3F] = true, -- ?
  [0x5D] = true, -- ]
  [0x7D] = true, -- }
  [0x7E] = true, -- ~
  [0xBB] = true, -- »
  [0x226B] = true, -- ≫MUCH GREATER-THAN
  [0x25A1] = true, -- □
  [0x25CB] = true, -- ○
  [0x2E80] = true, -- ⺀ CJK RADICAL REPEAT
  [0x3000] = true,
  [0x3003] = true, -- 〃 DITTO MARK
  [0x3005] = true, -- 々
  [0x3007] = true, -- 〇
  [0x3013] = true, -- 〓 GETA MARK
  [0x303B] = true, -- 〻 VERTICAL IDEOGRAPHIC ITERATION MARK
  [0x309B] = true, -- ゛ KATAKANA-HIRAGANA VOICED SOUND MARK
  [0x309C] = true, -- ゜ KATAKANA-HIRAGANA SEMI-VOICED SOUND MARK
  [0x309D] = true, -- ゝ HIRAGANA ITERATION MARK
  [0x309E] = true, -- ゞ HIRAGANA VOICED ITERATION MARK
  [0x30A0] = true, -- ゠ KATAKANA-HIRAGANA DOUBLE HYPHEN
  [0x30FC] = true, -- ー KATAKANA-HIRAGANA PROLONGED SOUND MARK
  [0x30FD] = true, -- ヽ KATAKANA ITERATION MARK
  [0x30FE] = true, -- ヾ KATAKANA VOICED ITERATION MARK
  [0xFE13] = true, -- ︓
  [0xFE14] = true, -- ︔
  [0xFE50] = true, -- ﹐ SMALL COMMA
  [0xFE51] = true, -- ﹑ SMALL IDEOGRAPHIC COMMA
  [0xFE52] = true, -- ﹒ SMALL FULL STOP
  [0xFE54] = true, -- ﹔
  [0xFE55] = true, -- ﹕
  [0xFE56] = true, -- ﹖
  [0xFE57] = true, -- ﹗
  [0xFE5A] = true, -- ﹚
  [0xFE5C] = true, -- ﹜
  [0xFE5E] = true, -- ﹞
  [0xFF1E] = true, -- ＞
  [0xFF70] = true, -- ｰ HALFWIDTH KATAKANA-HIRAGANA PROLONGED SOUND MARK
  [0xFF9E] = true, -- ﾞ HALFWIDTH KATAKANA VOICED SOUND MARK
  [0xFF9F] = true, -- ﾟ HALFWIDTH KATAKANA SEMI-VOICED SOUND MARK
  [0x3031] = true, -- 〱 VERTICAL KANA REPEAT MARK
  [0x3032] = true, -- 〲 VERTICAL KANA REPEAT WITH VOICED SOUND MARK
  [0x3033] = true, -- 〳 VERTICAL KANA REPEAT MARK UPPER HALF (as U+3035 is combining)
  [0x3034] = true, -- 〴 VERTICAL KANA REPEAT WITH VOICED SOUND MARK UPPER HALF (〃)
  -- dashes : suppress visible spacing after
  [0x2013] = 50, -- – (50: dashes)
  [0x2014] = 50, -- —
  [0x2015] = 50, -- ―
  [0x2025] = 50, -- ‥
  [0x2026] = 50, -- …
  [0xFE19] = 50, -- ︙ PRESENTATION FORM FOR VERTICAL HORIZONTAL ELLIPSIS
  [0xFE30] = 50, -- ︰ PRESENTATION FORM FOR VERTICAL TWO DOT LEADER
  [0xFE31] = 50, -- ︱ PRESENTATION FORM FOR VERTICAL EM DASH
  [0xFE32] = 50, -- ︲ PRESENTATION FORM FOR VERTICAL EN DASH
  [0xFE58] = 50, -- ﹘ SMALL EM DASH
  [0x301C] = 50, -- 〜 WAVE DASH
  [0x3030] = 50, -- 〰 WAVY DASH
  [0xFF5E] = 50, -- ～ FULLWIDTH TILDE
},{ __index = function (_,c)
  return is_hangul_jamo(c) -- chosong also is breakable_after
  or     is_noncjk_char(c)
  or     is_hanja(c)
  or     is_combining(c)
  or     is_kana(c)
  or     charclass[c] >= 2
end })
luatexko.breakableafter = breakable_after

local breakable_before = setmetatable({
  [0x28] = true, -- (
  [0x3C] = true, -- <
  [0x5B] = true, -- [
  [0x60] = true, -- `
  [0x7B] = true, -- {
  [0xAB] = true, -- «
  [0x226A] = true, -- ≪
  [0x25A1] = true, -- □
  [0x25CB] = true, -- ○
  [0x3000] = true,
  [0x3007] = true, -- 〇
  [0x3013] = true, -- 〓
  [0xFE59] = true, -- ﹙
  [0xFE5B] = true, -- ﹛
  [0xFE5D] = true, -- ﹝
  [0xFF1C] = true, -- ＜
  -- dashes : nobreak but stretching before
  [0x2013] = 10000, -- –
  [0x2014] = 10000, -- —
  [0x2015] = 10000, -- ―
  [0x2025] = 10000, -- ‥
  [0x2026] = 10000, -- …
  [0x3030] = 10000, -- 〰 WAVY DASH
  [0xFE19] = 10000, -- ︙
  [0xFE30] = 10000, -- ︰
  [0xFE31] = 10000, -- ︱
  [0xFE32] = 10000, -- ︲ PRESENTATION FORM FOR VERTICAL EN DASH
  [0xFE58] = 10000, -- ﹘ SMALL EM DASH
  -- small kana
  [0x3041] = 1000, [0x3043] = 1000, [0x3045] = 1000, [0x3047] = 1000,
  [0x3049] = 1000, [0x3063] = 1000, [0x3083] = 1000, [0x3085] = 1000,
  [0x3087] = 1000, [0x308E] = 1000, [0x3095] = 1000, [0x3096] = 1000,
  [0x30A1] = 1000, [0x30A3] = 1000, [0x30A5] = 1000, [0x30A7] = 1000,
  [0x30A9] = 1000, [0x30C3] = 1000, [0x30E3] = 1000, [0x30E5] = 1000,
  [0x30E7] = 1000, [0x30EE] = 1000, [0x30F5] = 1000, [0x30F6] = 1000,
  [0x31F0] = 1000, [0x31F1] = 1000, [0x31F2] = 1000, [0x31F3] = 1000,
  [0x31F4] = 1000, [0x31F5] = 1000, [0x31F6] = 1000, [0x31F7] = 1000,
  [0x31F8] = 1000, [0x31F9] = 1000, [0x31FA] = 1000, [0x31FB] = 1000,
  [0x31FC] = 1000, [0x31FD] = 1000, [0x31FE] = 1000, [0x31FF] = 1000,
  [0xFF67] = 1000, [0xFF68] = 1000, [0xFF69] = 1000, [0xFF6A] = 1000,
  [0xFF6B] = 1000, [0xFF6C] = 1000, [0xFF6D] = 1000, [0xFF6E] = 1000,
  [0xFF6F] = 1000,  [0x1B132] = 1000, [0x1B150] = 1000, [0x1B151] = 1000,
  [0x1B152] = 1000, [0x1B155] = 1000, [0x1B164] = 1000, [0x1B165] = 1000,
  [0x1B166] = 1000, [0x1B167] = 1000,
  -- nonstarter
  [0xA015] = false, -- YI SYLLABLE WU
},{ __index = function(_,c)
  return is_hangul(c)
  or     is_compat_jamo(c)
  or     is_chosong(c)
  or     is_hanja(c)
  or     is_kana(c)
  or     charclass[c] == 1
end
})
luatexko.breakablebefore = breakable_before

local function is_cjk_char (c)
  return is_hangul_jamo(c)
  or     is_hanja(c)
  or     is_kana(c)
  or     charclass[c] >= 1
  or     rawget(breakable_before, c) and c >= 0x2000
  or     rawget(breakable_after,  c) and c >= 0x2000
end

local active_processes = {}

-- font fallback

local force_hangul = {
  [0x21] = true, -- !
  [0x27] = true, -- '
  [0x28] = true, -- (
  [0x29] = true, -- )
  [0x2C] = true, -- ,
  [0x2E] = true, -- .
  [0x3A] = true, -- :
  [0x3B] = true, -- ;
  [0x3F] = true, -- ?
  [0x60] = true, -- `
  [0xB7] = true, -- ·
  [0x2014] = true, -- —
  [0x2015] = true, -- ―
  [0x2018] = true, -- ‘
  [0x2019] = true, -- ’
  [0x201C] = true, -- “
  [0x201D] = true, -- ”
  [0x2026] = true, -- …
  [0x203B] = true, -- ※
}
luatexko.forcehangulchars = force_hangul

local forcehf_id
function luatexko.updateforcehangul (value)
  if not forcehf_id then
    forcehf_id = luatexbase.new_whatsit("luatexko_forcehangulfont_whatsit")
  end
  local what = nodenew(whatsitid, "user_defined")
  what.user_id = forcehf_id
  what.type  = 108 -- lua_value : function
  what.value = value
  nodewrite(what)
end

local function process_fonts (head)
  local curr, currfont, currlang, newfont = head, 0, nohyphen, 0
  local to_free = { }
  while curr do
    local id = curr.id
    if id == glyphid then
      currfont, currlang = curr.font, curr.lang
      if curr.font ~= 0 -- exclude nullfont
        and not has_attribute(curr, unicodeattr) then

        local c = curr.char
        set_attribute(curr, unicodeattr, c)

        local done
        if is_combining(c) then
          local p = getprev(curr)
          if p and p.id == glyphid then
            if curr.font ~= p.font then
              curr.font = p.font
            end
            curr.lang = p.lang
            done = true
          end
          if is_jungsong(c) or is_jongsong(c) or hangul_tonemark[c] then
            done = false
            if hangul_tonemark[c]
              and not active_processes.reorderTM
              and not has_harf_data(curr.font) then
              luatexko.activate("reorderTM") -- activate reorderTM here
            end
          end
        end

        if not done then

          if curr.subtype == 1 and curr.lang ~= nohyphen and is_cjk_char(c) then
            curr.lang = langkor -- suppress hyphenation of cjk chars
          end

          local hf  = has_attribute(curr, hangulfontattr) or false
          local hjf = has_attribute(curr, hanjafontattr)  or false

          if hf and force_hangul[c]
            and fontoptions.is_widefont[curr.font] -- exclude legacy fonts
            and curr.lang ~= nohyphen and not fontoptions.monospaced[curr.font] -- exclude ttfamily
            then
            curr.font = hf
          elseif hf and has_attribute(curr, hangulbyhangulattr) and is_hangul_jamo(c) then
            curr.font = hf
          elseif hjf and has_attribute(curr, hanjabyhanjaattr) and is_hanja(c) then
            curr.font = hjf
          elseif not char_in_font(curr.font, c) then
            local fbf = has_attribute(curr, fallbackfontattr) or false
            local f = hf  and char_in_font(hf,  c) and hf
                   or hjf and char_in_font(hjf, c) and hjf
                   or fbf and char_in_font(fbf, c) and fbf
            if f then curr.font = f end
          end
        end
      end
      newfont = curr.font
    elseif id == glueid
      and currfont ~= 0
      and currfont ~= newfont
      and currlang ~= nohyphen
      and curr.subtype == 13 -- spaceskip
      -- fontloader's "node" mode sets space_stretch to zero
      -- when the font is a monospaced font (fontspec's \setmonofont
      -- command does the same thing), which we will bypass here
      -- for alignment of CJK and Latin glyphs in verbatim environment.
      -- See http://www.ktug.org/xe/index.php?document_srl=249772
      and not fontoptions.monospaced[currfont] then

      local params = getparameters(currfont)
      local oldwd, oldst, oldsh, oldsto, oldsho = getglue(curr)
      if params and oldsto == 0 and oldsho == 0 then
        local p = getprev(curr)
        while p and p.id == whatsitid do p = getprev(p) end
        local sf = p and p.char and tex.getsfcode(p.char) or 1000
        if sf == 0 or sf > 1000 then
          local p, pf = getprev(p), 0
          while p and pf == 0 do
            pf = p.char and tex.getsfcode(p.char) or 1000
            p = getprev(p)
          end
          if sf == 0 then sf = pf end
          if pf < 1000 then sf = 1000 end
        end
        if oldwd == (sf < 2000 and params.space or params.space+params.extra_space)
          and oldst == tex.sp(params.space_stretch * (sf/1000))
          and oldsh == tex.sp(params.space_shrink * (1000/sf)) then

          local newwd = fontoptions.hangulspaceskip[newfont]
          if newwd then
            setglue(curr,
                    sf < 2000 and newwd[1] or newwd[1]+newwd[4],
                    tex.sp(newwd[2] * (sf/1000)),
                    tex.sp(newwd[3] * (1000/sf)))
          end
        end
      end
    elseif id == discid then
      process_fonts(curr.pre)
      process_fonts(curr.post)
      process_fonts(curr.replace)
    elseif id == mathid then
      curr = end_of_math(curr)
    elseif id      == whatsitid  and
      curr.user_id == forcehf_id and
      curr.type    == 108 then -- lua_value

      local value = curr.value
      if type(value) == "function" then
        value()
      end

      noderemove(head, curr)
      to_free[#to_free+1] = curr
    end
    curr = getnext(curr)
  end

  for _, v in ipairs(to_free) do nodefree(v) end
end

-- linebreak

local is_blocking_node
do
  local blocking_nodes = {
    [hlistid]   = true,
    [vlistid]   = true,
    [ruleid]    = true,
    [discid]    = true,
    [glueid]    = true,
    [mathid]    = true,
    [dirid]     = true,
    [glyphid]   = true,
  }
  function is_blocking_node (curr)
    local id, subtype = curr.id, curr.subtype
    if id == hlistid then
      if subtype == 3 then return false end -- indentbox
      if curr.next and curr.next.id == ins_id then return false end -- footnote
    elseif has_attribute(curr, chartail) then
      return false
    end
    return blocking_nodes[id] or id == kernid and subtype == 1 -- userkern
  end
end

local function hbox_char_font (box, init, deep)
  local mynext = init and getnext  or getprev
  local curr   = init and box.list or nodeslide(box.list)
  while curr do
    local id = curr.id
    if has_attribute(curr, charhead) then -- various pass-over (especially unboxed) nodes
    elseif id == glyphid then
      local c = has_attribute(curr, unicodeattr) or curr.char
      if c and not is_combining(c) then
        return c, curr.font
      end
    elseif curr.list then
      return hbox_char_font(curr, init, deep)
    elseif deep then
    elseif curr.id == glueid and has_attribute(box, rubyattr) then -- ruby
    elseif is_blocking_node(curr) then
      return
    end
    curr = mynext(curr)
  end
end

local iwspaceOffattributeid = luatexbase.attributes.g__tag_interwordspaceOff_attr
local insert_glue_before
do
  local function char_orphan_penalty (curr, par)
    if par then
      local nn = getnext(curr)
      local nc = nn.char
      local ncl = nc and charclass[nc]
      while nn.id == whatsitid
        or nn.penalty == 10000
        or nc and (ncl >= 2 and ncl ~= 5 -- cjk closings
        or nc < 0xFF and rawget(breakable_after, nc) -- latin closings
        or is_combining(nc)) do
        nn = getnext(nn)
        nc = nn.char
        ncl = nc and charclass[nc]
      end
      if nn.id == glueid and nn.subtype == 15 then -- parfillskip
        return 1000 -- supress orphan
      end
    end
  end
  function insert_glue_before (head, curr, par, br, brb, classic, ict, dim, fid)
    local prev = getprev(curr)

    if prev and prev.penalty then
      -- repect user's penalty
    else
      local pena = not br and 10000
        or type(brb) == "number" and brb
        or char_orphan_penalty(curr, par)
        or fid and fontoptions.intercharpenalty[fid]
        or 50
      if not fid or pena ~= 0 then
        local pn = nodenew(penaltyid)
        pn.penalty = pena
        -- we inject penalty node before unhboxed ruby kern for line justification.
        head = insert_before(head, prev and has_attribute(prev, chartail) == 1 and prev or curr, pn)
      end
    end
    if not fid then return head end -- penalty only for non-glyph box + cjk

    dim = dim or 0
    local gl = nodenew(glueid)
    local en = fontoptions.en_size[fid]
    if ict then
      en = classic and en or en/4
      setglue(gl, en * ict[1] + dim, nil, en * ict[2])
    else
      local str = fontoptions.intercharstretch[fid] or stretch_f*en
      setglue(gl, dim, str, str*0.6)
    end

    if iwspaceOffattributeid then
      gl.attr = prev and prev.attr or curr.attr -- for less tagging
      set_attribute(gl, iwspaceOffattributeid, 1)
    end

    set_attribute(gl, inhibitglueattr, 1) -- suppress multiple run of unboxed nodes
    return insert_before(head, curr, gl)
  end
end

-- Scan forward from `curr` to the first node that is not a "char head" or
-- HarfBuzz-literal marker, and return it together with its anchor: the first
-- marker of the run immediately preceding it, or the node itself when no
-- marker preceded it. The anchor is the node before which glue and penalties
-- get inserted. Marker runs never carry across a real node, so this needs no
-- state between calls -- the four pre-linebreak passes below each drive it
-- from a plain `while` loop, which keeps their state in locals and leaves
-- them free to jump (as the math branches do via end_of_math).
local function next_real_node (curr)
  local anchor
  while curr do
    if has_attribute(curr, charhead) or harf_actual_literal(curr) == 1 then
      anchor = anchor or curr
    else
      return curr, anchor or curr
    end
    curr = getnext(curr)
  end
end

local process_cjk_punctuation_spacing, process_linebreak
do
  local function maybe_linebreak (head, curr, pc, pcl, cc, old, fid, par)
    local ccl = get_char_class(cc, old)
    if pc and cc and curr.lang ~= nohyphen and (is_cjk_char(pc) or is_cjk_char(cc)) then
      local brap, brbc = breakable_after[pc], breakable_before[cc]
      if brap == 50 and brbc == 10000 then -- skip dash-dash
      else
        local ict = intercharclass[pcl][ccl]
        local br  = brap and brbc or brap == 50 and is_noncjk_char(cc) -- allow dash-latin as well
        local dim = fontoptions.intercharacter[fid]
        head = insert_glue_before(head, curr, par, br, brbc, old, ict, dim, fid)
      end
    end
    return head, cc, ccl, fid
  end
  function process_cjk_punctuation_spacing (head, par)
    local pcl, pc, pf, old = 0, false, false, false
    --[[
    -- pcl: 앞 글자 클래스(0..7)
    -- pc : 앞 글자 코드
    -- pf : 앞 글자 폰트
    -- old: 현재 classic 모드임을 표시 (이 함수는 classic 모드에서만 동작한다)
    --]]
    local curr, anchor = next_real_node(head)
    while curr do
      local id = curr.id
      if id == glyphid and curr.lang ~= nohyphen then
        local cc = curr.char
        old = has_attribute(curr, classicattr)
        local ccl = get_char_class(cc, old)
        if old and intercharclass[pcl][ccl] then
          local cf = charclass[cc] == 0 and pf or curr.font
          head = maybe_linebreak(head, anchor, pc, pcl, cc, old, cf, par)
        end
        pcl, pc, pf = ccl, cc, curr.font
      elseif is_blocking_node(curr) then
        if id == glueid and (curr.subtype >= 13 and curr.subtype <= 15 -- spaceskip .. parfillskip
          or has_attribute(curr, inhibitglueattr)) then
          pcl, pc, pf = 0, false, false
        else
          if pf and old and intercharclass[pcl][0] then
            head = maybe_linebreak(head, anchor, pc, pcl, 0x4E00, old, pf, par)
          end
          pcl, pc, pf = 0, 0x4E00, false
        end
      end
      curr, anchor = next_real_node(getnext(curr))
    end
    return head
  end
  function process_linebreak (head, par)
    local pc, pcl, pf = false, 0, false
    --[[
    -- pc : 앞 글자 코드
    -- pcl: 앞 글자 클래스(0..7)
    -- pf : 앞 글자 폰트
    --]]
    local curr, anchor = next_real_node(head)
    while curr do
      local id = curr.id
      if id == glyphid then
        local c = has_attribute(curr, unicodeattr) or curr.char
        if c and not is_combining(c) then
          local old = has_attribute(curr, classicattr)
          local cjk = is_cjk_char(c)
          local f = cjk and curr.font or pf or curr.font
          if old and cjk and pc == -1 then -- penalty only (f is nil below) for non-glyph box + cjk
            head = insert_glue_before(head, anchor, par, true, false)
            pc, pf, pcl = c, f, get_char_class(c, old)
          else
            head, pc, pcl, pf = maybe_linebreak(head, anchor, pc, pcl, c, old, f, par)
          end
        end

      elseif (id == hlistid or id == vlistid) and is_blocking_node(curr) then
        local old = has_attribute(curr, classicattr)
        local c, f = hbox_char_font(curr, true)
        if c then
          head = maybe_linebreak(head, anchor, pc, pcl, c, old, pf or f, false) -- par is false
        elseif old and pc and is_cjk_char(pc) then -- penalty only
          head = insert_glue_before(head, anchor, false, true, false)
        end
        c, f = hbox_char_font(curr)
        pc, pf, pcl  = c or old and -1, pf or f, c and get_char_class(c, old) or 0

      elseif id == mathid then
        pc, pcl, curr = 0x30, 0, end_of_math(curr)

      elseif id == dirid then
        if curr.dir:sub(1,1) == "+" then -- push dir
          local n = getnext(curr)
          if n.id == glyphid then
            local old = has_attribute(n, classicattr)
            head = maybe_linebreak(head, anchor, pc, pcl, n.char, old, n.font, par)
          end
          pc, pcl = false, 0
        end

      elseif is_blocking_node(curr) then
        pc, pcl = false, 0
      end
      curr, anchor = next_real_node(getnext(curr))
    end
    return head
  end
end

-- interhangul & interlatincjk

local process_interhangul
do
  local function do_interhangul_option (head, curr, pc, c, fontid, par)
    local cc = (is_hangul(c) or is_compat_jamo(c) or is_chosong(c)) and 1 or 0

    if cc*pc == 1 and curr.lang ~= nohyphen then
      local dim = fontoptions.interhangul[fontid]
      if dim then
        head = insert_glue_before(head, curr, par, true, true, false, false, dim, fontid)
      end
    end

    return head, cc, fontid
  end
  function process_interhangul (head, par)
    local pc, pf = 0, false
    --[[
    -- pc: 앞 글자 한글 여부(1 or 0)
    -- pf: 앞 글자 폰트
    --]]
    local curr, anchor = next_real_node(head)
    while curr do
      local id = curr.id
      if id == glyphid then
        local c = has_attribute(curr, unicodeattr) or curr.char
        if c and not is_combining(c) then
          head, pc, pf = do_interhangul_option(head, anchor, pc, c, curr.font, par)
        end

      elseif id == hlistid and is_blocking_node(curr) then
        local c, f = hbox_char_font(curr, true)
        if c then
          head, pc, pf = do_interhangul_option(head, anchor, pc, c, pf or f, false)
        end
        c, f = hbox_char_font(curr)
        pc, pf = c and is_hangul_jamo(c) and 1 or 0, pf or f

      elseif id == mathid then
        pc, curr = 0, end_of_math(curr)
      elseif id == dirid then
        if curr.dir:sub(1,1) == "+" then
          local n = getnext(curr)
          if n.id == glyphid then
            head = do_interhangul_option(head, anchor, pc, n.char, n.font, par)
          end
          pc = 0
        end
      elseif is_blocking_node(curr) then
        pc = 0
      end
      curr, anchor = next_real_node(getnext(curr))
    end
    return head
  end
end

local process_interlatincjk
do
  local function do_interlatincjk_option (head, curr, p, pc, pf, c, cf, par)
    local cc = is_cjk_char(c) and 1 or is_noncjk_char(c) and 2 or 0
    local f = cc == 1 and cf or pf

    if p and cc*pc == 2 and curr.lang ~= nohyphen then
      local brb = cc == 2 or breakable_before[c] -- numletter != br_before
      if brb and brb ~= 10000 and breakable_after[p] == true then -- skip latin-dash and dash-latin
        local dimc = fontoptions.interlatincjk[cf] or 0
        local dimp = fontoptions.interlatincjk[pf] or 0
        local dim  = dimc > dimp and dimc or dimp
        if dim ~= 0 then
          head = insert_glue_before(head, curr, par, true, brb, false, false, dim, f)
        end
      end
    end

    return head, cc, f, c
  end
  function process_interlatincjk (head, par)
    local pc, pf, p = 0, false, false
    --[[
    -- pc: 앞 글자 cjk 여부(1 = cjk, 2 = non-cjk, 0 = other)
    -- pf: 앞 글자 폰트
    -- p : 앞 글자 코드
    --]]
    do -- seed pf with the font of the first CJK glyph in the list
      local curr = head
      while curr do
        if curr.id == glyphid and is_cjk_char(curr.char) then
          pf = curr.font
          break
        end
        curr = getnext(curr)
      end
    end

    local curr, anchor = next_real_node(head)
    while curr do
      local id = curr.id
      if id == glyphid then
        local c = has_attribute(curr, unicodeattr) or curr.char
        if c and not is_combining(c) then
          head, pc, pf, p = do_interlatincjk_option(head, anchor, p, pc, pf, c, curr.font, par)
        end

      elseif id == hlistid and is_blocking_node(curr) then
        local c, f = hbox_char_font(curr, true)
        if c then
          head = do_interlatincjk_option(head, anchor, p, pc, pf, c, pf or f, false)
        end
        c, f = hbox_char_font(curr)
        pc = c and (is_cjk_char(c) and 1 or is_noncjk_char(c) and 2) or 0
        pf, p  = pf or f, c

      elseif id == mathid then
        if pc == 1 then
          head = do_interlatincjk_option(head, anchor, p, pc, pf, 0x30, pf, par)
        end
        curr, pc, p = end_of_math(curr), 2, 0x30

      elseif id == dirid then
        if curr.dir:sub(1,1) == "+" then
          local n = getnext(curr)
          if n.id == glyphid then
            head = do_interlatincjk_option(head, anchor, p, pc, pf, n.char, n.font, par)
          end
          pc, p = 0, false
        end

      elseif is_blocking_node(curr) then
        pc, p = 0, false
      end
      curr, anchor = next_real_node(getnext(curr))
    end
    return head
  end
end

-- compress punctuations

local function process_glyph_width (head)
  local curr = head
  while curr do
    local id = curr.id
    if id == glyphid then
      if curr.lang ~= nohyphen
        and not (verticalattr and has_attribute(curr, verticalattr)) -- avoid multiple run
        and fontoptions.compresspunctuations[curr.font] then

        local cc = has_attribute(curr, unicodeattr) or curr.char
        local old = has_attribute(curr, classicattr)
        local class = get_char_class(cc, old)
        if class >= 1 and class <= 4 and
          (old or cc < 0x2000 or cc > 0x202F) then -- exclude general puncts

          local gpos = class == 1 and getprev(curr) or getnext(curr)
          gpos = gpos and gpos.id == kernid and gpos.subtype == 0 -- fontkern

          if not gpos then -- avoid multiple run
            local diff = char_in_font(curr.font, curr.char).luatexko_diff or 0
            local wd = fontoptions.en_size[curr.font] - (curr.width + diff)
            if wd ~= 0 then
              local k = nodenew(kernid) -- fontkern (subtype 0) is default
              k.kern = class == 3 and wd/2 or wd
              if class == 1 then
                set_attribute(k, charhead, 1)
                head = insert_before(head, curr, k)
              elseif class == 2 or class == 4 then
                head, curr = insert_after(head, curr, k)
              else
                local k2 = nodecopy(k)
                set_attribute(k, charhead, 1)
                head = insert_before(head, curr, k)
                head, curr = insert_after(head, curr, k2)
              end
            end
          end
        end
      end
    elseif id == mathid then
      curr = end_of_math(curr)
    end
    curr = getnext(curr)
  end
  return head
end

-- remove classic spaces

local function process_remove_spaces (head)
  local curr, to_free, sp, cjk = head, {}, false, false
  while curr do
    local id = curr.id
    if id == glyphid and curr.lang ~= nohyphen and has_attribute(curr, classicattr)
      and fontoptions.removeclassicspaces[curr.font] then
      local c = has_attribute(curr, unicodeattr) or curr.char or 0
      if not is_combining(c) then
        local cc = is_cjk_char(c) and 1 or 0
        if sp and cjk and (cjk + cc) == 2 then
          head = noderemove(head, sp)
          to_free[#to_free+1] = sp
        end
        cjk, sp = cc, false
      end
    elseif cjk and id == glueid
      and curr.subtype == 13 -- spaceskip
      and has_attribute(curr, classicattr) then
      sp = curr
    elseif id == mathid then
      curr, cjk, sp = end_of_math(curr), false, false
    elseif id == hlistid or is_blocking_node(curr) then
      cjk, sp = false, false
    end
    curr = getnext(curr)
  end
  for _,v in ipairs(to_free) do nodefree(v) end
  return head
end

-- josa

local process_josa
do
  local josa_table = {
    --          리을,   중성,   종성
    [0xAC00] = {0xC774, 0xAC00, 0xC774}, -- 가 = 이, 가, 이
    [0xC740] = {0xC740, 0xB294, 0xC740}, -- 은 = 은, 는, 은
    [0xC744] = {0xC744, 0xB97C, 0xC744}, -- 을 = 을, 를, 을
    [0xC640] = {0xACFC, 0xC640, 0xACFC}, -- 와 = 과, 와, 과
    [0xC73C] = {nil,    nil,    0xC73C}, -- 으(로) =   ,  , 으
    [0xC774] = {0xC774, nil,    0xC774}, -- 이(라) = 이,  , 이
  }

  local hanja2hangul = { }
  local function add_to_hanja2hangul (filename, i, last)
    local f = kpse.find_file(filename)
    if f then
      for c in io.lines(f) do
        hanja2hangul[i] = tonumber(c)
        i = i + 1
      end
    else
      warning("cannot find %s", filename)
      for c = i, last do
        hanja2hangul[c] = c
      end
    end
  end

  local josa_code = setmetatable({
    [0x30] = 3,   [0x31] = 1,   [0x33] = 3,   [0x36] = 3,
    [0x37] = 1,   [0x38] = 1,   [0x4C] = 1,   [0x4D] = 3,
    [0x4E] = 3,   [0x6C] = 1,   [0x6D] = 3,   [0x6E] = 3,
    [0x2160] = 1, [0x2162] = 3, [0x2165] = 3, [0x2166] = 1,
    [0x2167] = 1, [0x2169] = 3, [0x216A] = 1, [0x216C] = 3,
    [0x216D] = 3, [0x216E] = 3, [0x216F] = 3, [0x2170] = 1,
    [0x2172] = 3, [0x2175] = 3, [0x2176] = 1, [0x2177] = 1,
    [0x2179] = 3, [0x217A] = 1, [0x217C] = 3, [0x217D] = 3,
    [0x217E] = 3, [0x217F] = 3, [0x2460] = 1, [0x2462] = 3,
    [0x2465] = 3, [0x2466] = 1, [0x2467] = 1, [0x2469] = 3,
    [0x246A] = 1, [0x246C] = 3, [0x246F] = 3, [0x2470] = 1,
    [0x2471] = 1, [0x2473] = 3, [0x2474] = 1, [0x2476] = 3,
    [0x2479] = 3, [0x247A] = 1, [0x247B] = 1, [0x247D] = 3,
    [0x247E] = 1, [0x2480] = 3, [0x2483] = 3, [0x2484] = 1,
    [0x2485] = 1, [0x2487] = 3, [0x2488] = 1, [0x248A] = 3,
    [0x248D] = 3, [0x248E] = 1, [0x248F] = 1, [0x2491] = 3,
    [0x2492] = 1, [0x2494] = 3, [0x2497] = 3, [0x2498] = 1,
    [0x2499] = 1, [0x249B] = 3, [0x24A7] = 1, [0x24A8] = 3,
    [0x24A9] = 3, [0x24C1] = 1, [0x24C2] = 3, [0x24C3] = 3,
    [0x24DB] = 1, [0x24DC] = 3, [0x24DD] = 3, [0x3139] = 1,
    [0x3203] = 1, [0x3263] = 1, [0xFF10] = 3, [0xFF11] = 1,
    [0xFF13] = 3, [0xFF16] = 3, [0xFF17] = 1, [0xFF18] = 1,
    [0xFF2C] = 1, [0xFF2D] = 3, [0xFF2E] = 3, [0xFF4C] = 1,
    [0xFF4D] = 3, [0xFF4E] = 3,
  },{ __index = function(t, cc)
    local c = cc
    -- xetexko에 포함된 .tab 파일들을 이용해 한자를 한글로 변환
    if c >= 0x4E00 and c <= 0x9FA5 then
      if not hanja2hangul[c] then
        add_to_hanja2hangul("hanja_hangul.tab", 0x4E00, 0x9FA5)
      end
      c = hanja2hangul[c]
    elseif c >= 0xF900 and c <= 0xFA2D then
      if not hanja2hangul[c] then
        add_to_hanja2hangul("hanjacom_hangul.tab", 0xF900, 0xFA2D)
      end
      c = hanja2hangul[c]
    elseif c >= 0x3400 and c <= 0x4DB5 then
      if not hanja2hangul[c] then
        add_to_hanja2hangul("hanjaexa_hangul.tab", 0x3400, 0x4DB5)
      end
      c = hanja2hangul[c]
    end
    if is_hangul(c) then
      c = (c - 0xAC00) % 28 + 0x11A7
    end
    if is_chosong(c) then
      c = c == 0x1105 and 1 or 3
      t[cc] = c; return c
    elseif is_jungsong(c) then
      c = c ~= 0x1160 and 2
      t[cc] = c; return c
    elseif is_jongsong(c) then
      c = c == 0x11AF and 1 or 3
      t[cc] = c; return c
    elseif is_noncjk_char(c) and c <= 0x7A
      or c >= 0x2160 and c <= 0x217F -- roman
      or c >= 0x2460 and c <= 0x24E9 -- ①
      or c >= 0x314F and c <= 0x3163 or c >= 0x3187 and c <= 0x318E -- ㅏ
      or c >= 0x320E and c <= 0x321E -- ㈎
      or c >= 0x326E and c <= 0x327F -- ㉮
      or c >= 0xFF10 and c <= 0xFF19 -- ０
      or c >= 0xFF21 and c <= 0xFF3A -- Ａ
      or c >= 0xFF41 and c <= 0xFF5A -- ａ
      then
        t[cc] = 2; return 2
    elseif c >= 0x3131 and c <= 0x314E or c >= 0x3165 and c <= 0x3186 -- ㄱ
      or c >= 0x3200 and c <= 0x320D -- ㈀
      or c >= 0x3260 and c <= 0x326D -- ㉠
      then
        t[cc] = 3; return 3
    end
  end })

  local function prevjosacode (n, parenlevel, ignore_parens)
    local josacode
    while n do
      local id = n.id
      if id == glyphid then
        local c = has_attribute(n, unicodeattr) or n.char -- beware hlist/vlist
        if ignore_parens and c == 0x29 then -- )
          parenlevel = parenlevel + 1
        elseif ignore_parens and c == 0x28 then -- (
          parenlevel = parenlevel - 1
        elseif parenlevel <= 0 then
          josacode = josa_code[c]
          if josacode then break end
        end
      elseif id == hlistid or id == vlistid then
        local list = n.list
        if list then
          josacode, parenlevel = prevjosacode(nodeslide(list), parenlevel, ignore_parens)
          if josacode then break end
        end
      end
      n = getprev(n)
    end
    return josacode, parenlevel
  end

  function process_josa (head)
    local curr, tofree = head, {}
    while curr do
      local id = curr.id
      if id == glyphid then
        local autojosaattr = has_attribute(curr, autojosaattr)
        if autojosaattr then
          local cc = curr.char
          if cc == 0xC774 then
            local n = getnext(curr)
            if n and n.char and is_hangul(n.char) then
            else
              cc = 0xAC00
            end
          end
          local t = josa_table[cc]
          if t then
            cc = t[prevjosacode(getprev(curr), 0, autojosaattr > 0) or 3]
            if cc then
              curr.char = cc
            else
              head = noderemove(head, curr)
              tofree[#tofree+1] = curr
            end
          end
          unset_attribute(curr, autojosaattr)
        end
      elseif id == mathid then
        curr = end_of_math(curr)
      end
      curr = getnext(curr)
    end
    for _,v in ipairs(tofree) do nodefree(v) end
    return head
  end
end

-- dotemph

local shift_put_top
do
  local function get_font_yshift (f, dotem)
    local off = 0
    if f then
      off = off + (fontoptions.charraise[f] or 0)
      if fontoptions.is_vertical[f] then
        off = off + (fontoptions.vertcharraise[f]or 0)
        - (fontoptions.en_size[f] or 0)*(dotem and 0.8 or 0.5) -- lower a little
      end
    end
    return off
  end
  function shift_put_top (bot, top, dotem)
    local shift = top.shift or 0

    if bot.id == hlistid then
      bot = has_glyph(bot.list) or {}
    end
    local bot_off = get_font_yshift(bot.font, dotem)

    if bot_off ~= 0 then
      if top.id == hlistid then
        top = has_glyph(top.list) or {}
      end
      local top_off = get_font_yshift(top.font, dotem)

      return shift + top_off - bot_off -- minus is raise, plus is lower
    end

    return shift
  end
end

local dotemphbox = {}
luatexko.dotemphbox = dotemphbox

local dotemph_id
function luatexko.dotemphboundary (i)
  if not dotemph_id then
    dotemph_id = luatexbase.new_whatsit("luatexko_dotemph_whatsit")
  end
  local what = nodenew(whatsitid, "user_defined")
  what.user_id = dotemph_id
  what.type  = 100 -- lua_number
  what.value = i
  nodewrite(what)
end

local function process_dotemph (head)
  local to_free = { }
  local curr, anchor = next_real_node(head)
  while curr do
    if curr.list then
      curr.list = process_dotemph(curr.list)

    elseif curr.id == glyphid then
      local dotattr = has_attribute(curr, dotemphattr)
      if dotattr and dotemphbox[dotattr] then
        unset_attribute(curr, dotemphattr) -- avoid multiple run

        local c = has_attribute(curr, unicodeattr) or curr.char
        if is_hangul(c) or is_compat_jamo(c) or is_chosong(c) or is_hanja(c) or is_kana(c) then

          local box = node.copy(dotemphbox[dotattr])

          -- consider charraise
          box.shift = shift_put_top(curr, box, true)

          local basewd = curr.width
          if hangul_tonemark[curr.char] then -- horizontal hangul tonemark
            basewd = 2 * basewd
          end
          -- put the dot before base syllable
          local n = getnext(curr)
          while n do
            if n.id == glyphid then
              if not is_combining(has_attribute(n, unicodeattr) or n.char) then break end
              basewd = basewd + n.width
            elseif n.id == kernid and n.subtype == 0 then -- fontkern
              basewd = basewd + n.kern
            elseif has_attribute(n, charhead) or (n.id == whatsitid and
              (n.subtype == restore_whatsit or harf_actual_literal(n) == 2)) then -- pass
            else
              break
            end
            n = getnext(n)
          end

          local shift = (basewd - box.width)/2
          if shift ~= 0 then
            local list = box.list
            local k = nodenew(kernid)
            k.kern, k.subtype = shift, 1 -- userkern
            box.list = insert_before(list, list, k)
          end

          box.width = 0
          set_attribute(box, charhead, 1)
          head = insert_before(head, anchor, box)
        end
      end

    elseif curr.id == whatsitid  and
      curr.user_id == dotemph_id and
      curr.type    == 100 then -- lua_number

      local val = curr.value
      nodefree(dotemphbox[val])
      dotemphbox[val] = nil

      to_free[#to_free+1] = curr
      head = noderemove(head, curr)
    end
    curr, anchor = next_real_node(getnext(curr))
  end

  for _, v in ipairs(to_free) do nodefree(v) end
  return head
end

-- uline

function luatexko.get_strike_out_down (box)
  local c, f = hbox_char_font(box, true, true) -- ignore blocking nodes
  if c and f then
    local down
    local ex = get_font_param(f, "x_height") or tex.sp"1ex"
    if is_cjk_char(c) then
      local ascender, descender = table.unpack(fontoptions.asc_desc[f])
      if ascender and descender then
        down = descender - (ascender + descender)/2
      else
        down = -0.667*ex
      end
    else
      down = -0.5*ex
    end

    local raise = fontoptions.charraise[f] or 0
    return down - raise
  end
  return -tex.sp"0.5ex"
end

local uline_id
function luatexko.ulboundary (i, n, subtype)
  if not uline_id then
    uline_id = luatexbase.new_whatsit("luatexko_uline_whatsit")
  end
  local what = nodenew(whatsitid, "user_defined")
  what.user_id = uline_id
  if n then
    while n.id ~= ruleid and n.id ~= hlistid and n.id ~= vlistid do
      n = getnext(n)
    end
    what.type  = 108 -- lua_value : table
    what.value = { i, nodecopy(n), subtype }
  else
    what.type  = 100 -- lua_number
    what.value = i
  end
  nodewrite(what)
end

local process_uline
do
  local white_nodes = {
    [glueid]    = true,
    [penaltyid] = true,
    [kernid]    = true,
    [whatsitid] = true,
  }

  local function skip_white_nodes (n, ltr)
    local nextnode = ltr and getnext or getprev
    while n do
      if has_attribute(n, charhead) or harf_actual_literal(n)
        or n.id == kernid and n.subtype == 0 -- fontkern
        or not white_nodes[n.id] then
        break
      end
      n = nextnode(n)
    end
    return n
  end

  local function draw_uline (head, curr, parent, t, final)
    local start, list, subtype = t.start or head, t.list, t.subtype
    start = skip_white_nodes(start, true)
    if final and start then
      nodeslide(start) -- to get correct getprev.
    end
    curr  = skip_white_nodes(curr)
    if start and curr then
      curr = getnext(curr) or curr

      local len = parent and rangedimensions(parent, start, curr)
                         or  dimensions(start, curr)
      if len and len ~= 0 then
        local g = nodenew(glueid)
        setglue(g, len)
        g.subtype = subtype
        g.leader  = final and list or nodecopy(list)
        g.attr    = list.attr
        set_attribute(g, charhead, 1)
        local k = nodenew(kernid)
        k.kern = -len
        k.subtype = 1 -- userkern
        set_attribute(k, charhead, 2)
        head = insert_before(head, start, g)
        head = insert_before(head, start, k)
      end
    end
    return head
  end

  local ulitems = {}

  function process_uline (head, parent, level)
    local curr, level = head, level or 0
    local to_free = { }
    while curr do
      if curr.list then
        curr.list = process_uline(curr.list, curr, level+1)

      elseif curr.id == whatsitid and curr.user_id == uline_id then

        local value = curr.value
        if curr.type == 108 then -- lua_value
          local count, list, subtype = table.unpack(value)
          ulitems[count] = {
            list    = list,
            subtype = subtype,
            level   = level,
            start   = getnext(curr) or curr,
          }
        else
          local item = ulitems[value]
          if item then
            head = draw_uline(head, curr, parent, item, true)
            ulitems[value] = nil
          end
        end

        to_free[#to_free+1] = curr
        head = noderemove(head, curr)

      end
      curr = getnext(curr)
    end

    for _, item in pairs(ulitems) do
      if item.level == level then
        head = draw_uline(head, nodeslide(head), parent, item)
        item.start = nil
      end
    end

    for _, v in ipairs(to_free) do nodefree(v) end
    return head
  end
end

-- ruby

local rubybox = {}
luatexko.rubybox = rubybox

function luatexko.getrubystretchfactor (box)
  local _, fid = hbox_char_font(box, true, true)
  local str = fontoptions.intercharstretch[fid]
  if str then
    local em = fontoptions.en_size[fid] * 2
    token.set_macro("luatexkostretchfactor", ("%.4f"):format(str/em/2))
  end
end

local function process_ruby_pre_linebreak (head)
  local curr = head
  while curr do
    local id = curr.id
    if id == hlistid then
      local rubyid = has_attribute(curr, rubyattr)
      if rubyid then
        local k2, r2
        local ruby_t = rubybox[rubyid]
        if ruby_t and ruby_t[3] then -- rubyoverlap
          local side = (ruby_t[1].width - curr.width)/2
          if side > 0 then -- ruby is wide
            local k, r = nodenew(kernid), nodenew(ruleid)
            k.subtype, k.kern = 1, -side -- userkern
            r.width, r.height, r.depth = side, 0, 0
            k2, r2 = nodecopy(k), nodecopy(r)

            local prev = curr.prev -- 문단 첫머리에 루비 돌출 방지
            if prev and
              ( prev.id == localparid or
              prev.id == hlistid and prev.subtype == 3 and prev.width == 0 ) then -- indentbox
              k.kern = 0
            end -- TODO: \rubyoverlap \setbox0\hbox{\ruby{short}{loooooong}} \noindent\copy0\copy0

            set_attribute(k, charhead, 1)
            set_attribute(r, charhead, 2)
            head = insert_before(head, curr, k)
            head = insert_before(head, curr, r)
          end
          ruby_t[3] = false
        end

        -- integrated from process_ruby_post_linebreak()
        local ruby = ruby_t and ruby_t[1]
        if ruby then
          local side = (curr.width - ruby.width)/2

          if not k2 then -- ruby is not wider than base
            local basefirst, rubyfirst = has_glyph(curr.list), has_glyph(ruby.list)
            if basefirst and hangul_tonemark[basefirst.char] -- horizontal hangul tonemark
              and not (rubyfirst and hangul_tonemark[rubyfirst.char]) then -- no horiz. hangul tm.
              side = side + (basefirst.width or 0)/2
            end
          end

          if side ~= 0 then
            local list = ruby.list
            local k = nodenew(kernid)
            k.kern, k.subtype = side, 1 -- userkern
            ruby.list = insert_before(list, list, k)
          end
          ruby.width = 0

          -- consider charraise
          local shift = shift_put_top(curr, ruby, false)

          local f, ascender, descender
          _, f = hbox_char_font(curr, true, true)
          ascender  = fontoptions.asc_desc[f][1] or curr.height
          _, f = hbox_char_font(ruby, true, true)
          descender = fontoptions.asc_desc[f][2] or ruby.depth

          ruby.shift = shift - ascender - descender - ruby_t[2] -- rubysep
          set_attribute(ruby, charhead, 1)
          head = insert_before(head, curr, ruby)
        end

        if k2 then
          set_attribute(r2, chartail, 2)
          set_attribute(k2, chartail, 1)
          head, curr = insert_after(head, curr, r2)
          local n = getnext(curr)
          while n and n.id == penaltyid do -- skip penalty node for justification
            n, curr = curr, getnext(n)
          end
          head, curr = insert_after(head, curr, k2)
        end

        rubybox[rubyid] = nil
      end
    elseif id == mathid then
      curr = end_of_math(curr)
    end
    curr = getnext(curr)
  end
  return head
end

-- reorder tone marks

local process_reorder_tonemarks
do
  local function conv_tounicode (uni)
    if uni < 0x10000 then
      return ("%04X"):format(uni)
    else -- surrogate
      uni = uni - 0x10000
      local high = uni // 0x400 + 0xD800
      local low  = uni %  0x400 + 0xDC00
      return ("%04X%04X"):format(high, low)
    end
  end
  local function my_node_props (n)
    local t = getproperty(n)
    if not t then
      t = {}
      setproperty(n, t)
    end
    t.luatexko = t.luatexko or {}
    return t.luatexko
  end
  local function pdfliteral_direct_actual (syllable)
    local data
    if syllable then
      local t = {}
      for _,v in ipairs(syllable) do
        t[#t + 1] = conv_tounicode(v)
      end
      data = ("/Span<</ActualText<FEFF%s>>>BDC"):format(table.concat(t))
    else
      data = "EMC"
    end
    local what = nodenew(whatsitid, literal_whatsit)
    what.mode = 2 -- directmode
    what.data = data
    if syllable then
      my_node_props(what).startactualtext = syllable
    else
      my_node_props(what).endactualtext = true
    end
    return what
  end
  local function goto_end_actualtext (curr)
    local n = getnext(curr)
    while n do
      if n.id == whatsitid and
        n.mode == 2 and -- directmode
        my_node_props(n).endactualtext then
        curr = n; break
      end
      n = getnext(n)
    end
    return curr
  end
  function process_reorder_tonemarks (head)
    local curr, init = head
    while curr do
      local id = curr.id
      if id == glyphid and
        not has_harf_data(curr.font) and
        fontoptions.is_hangulscript[curr.font] then

        local uni = has_attribute(curr, unicodeattr) or curr.char
        if is_hangul(uni) or is_chosong(uni) or uni == 0x25CC then
          init = curr
        elseif is_jungsong(uni) or is_jongsong(uni) then
        elseif hangul_tonemark[uni] then
          if init then
            local n, syllable = init, { init = init }
            while n do
              if n.id == glyphid then
                local u = has_attribute(n, unicodeattr) or n.char
                if u then syllable[#syllable+1] = u end
              end
              if n == curr then break end
              n = getnext(n)
            end

            if #syllable > 1 and curr.width > 0 then
              local TM = curr

              local actual    = pdfliteral_direct_actual(syllable)
              local endactual = pdfliteral_direct_actual()
              actual.attr, endactual.attr = TM.attr, TM.attr -- for tagged pdf
              set_attribute(actual, charhead, 1)
              head = insert_before(head, init, actual)
              head, curr = insert_after(head, curr, endactual)

              head = noderemove(head, TM)
              head, TM = insert_before(head, init, TM)

              local n, i = TM, 1
              while n.char do
                set_attribute(n, unicodeattr, syllable[i]) -- reorder unicode attr
                n, i = getnext(n), i+1
              end
            end

            init = nil
          elseif char_in_font(curr.font, 0x25CC) then -- isolated tone mark
            local dotcircle = nodecopy(curr)
            dotcircle.char = 0x25CC
            if curr.width > 0 then
              local actual    = pdfliteral_direct_actual{ init = curr, uni }
              local endactual = pdfliteral_direct_actual()
              actual.attr, endactual.attr = dotcircle.attr, dotcircle.attr -- for tagged pdf
              set_attribute(actual, charhead, 1)
              set_attribute(curr, unicodeattr, 0x25CC)
              set_attribute(dotcircle, unicodeattr, uni)
              head = insert_before(head, curr, actual)
              head, curr = insert_after(head, curr, dotcircle)
              head, curr = insert_after(head, curr, endactual)
            else
              head = insert_before(head, curr, dotcircle)
            end
          end

        else
          init = nil
        end
      elseif id == kernid and curr.subtype ~= 1 then -- skip non-userkern
      elseif id == whatsitid then
        if curr.mode == 2 -- directmode
          and my_node_props(curr).startactualtext then
          curr, init = goto_end_actualtext(curr), nil
        end
      else
        init = nil
        if id == mathid then curr = end_of_math(curr) end
      end
      curr = getnext(curr)
    end
    return head
  end
end

-- vertical font

local function activate_process (cbnam, cbfun, name, first)
  if not active_processes[name] then
    local fmt = "luatexko.%s.%s"
    local desc = fmt:format(cbnam, name)
    if first then
      for k, v in pairs(active_processes) do
        if v == cbnam then
          luatexbase.declare_callback_rule(v, desc, "before", fmt:format(v, k))
        end
      end
    end
    luatexbase.add_to_callback(cbnam, cbfun, desc)
    active_processes[name] = cbnam
  end
end

local get_hb_char_bbox
if harfbuzz then
  local cachedir
  local function get_cachedir ()
    if cachedir == false then return end
    local texmfvar = kpse.var_value"TEXMFVAR"
    if texmfvar and texmfvar ~= "" then
      for _,v in ipairs(texmfvar:explode(os.type == "unix" and ":" or ";")) do
        local dir = ("%s/%s"):format(v,"luatexko_cache")
        if lfs.attributes(dir,"mode") ~= "directory" then lfs.mkdirp(dir) end
        if file.is_writable(dir) then cachedir = dir; return dir end
      end
    end
    cachedir = false
  end
  local function get_cache_name (fontdata, suffix)
    local version = fontdata.hb.shared.face:get_name(harfbuzz.ot.NAME_ID_VERSION_STRING)
    or assert(lfs.attributes(fontdata.specification.filename, "modification"))
    local name = ("%s_%s_%s"):format(fontdata.fullname, version, suffix)
    local hexa = ('%02x'):rep(256/8):format(sha2.digest256(name):byte(1, -1))
    return ("%s/%s.lua"):format(cachedir, hexa)
  end
  local function get_cache_data (fontdata, suffix)
    local name = get_cache_name(fontdata, suffix)
    if lfs.attributes(name, "mode") == "file" then
      return require(name)
    end
  end
  local function store_cache_data (fontdata, suffix, data)
    local name = get_cache_name(fontdata, suffix)
    table.tofile(name, data, "return")
  end
  local function get_char_bbox (hbfont, gid)
    local t = hbfont:get_glyph_extents(gid) -- quite slow for CFF
    if t then
      return { t.x_bearing, t.y_bearing + t.height, t.x_bearing + t.width, t.y_bearing }
    end
  end
  function get_hb_char_bbox (fontdata, index)
    local hbfont = fontdata.hb.shared.font
    local key = tostring(hbfont)
    local bboxes = fontoptions.hb_char_bbox[key]
    local bbox = bboxes and bboxes[index]
    if bbox then return bbox end
    if cachedir or get_cachedir() then
      local data = get_cache_data(fontdata, "bbox")
      if not data then
        data = { }
        for i = 0, 65534 do
          local t = get_char_bbox(hbfont, i)
          if not t then break end
          data[i] = t
        end
        store_cache_data(fontdata, "bbox", data)
      end
      fontoptions.hb_char_bbox[key], bbox = data, data[index]
      if bbox then return bbox end
    end
    if not bboxes then
      fontoptions.hb_char_bbox[key] = { }
      bboxes = fontoptions.hb_char_bbox[key]
    end
    bbox = get_char_bbox(hbfont, index) or {0,0,0,0}
    bboxes[index] = bbox
    return bbox
  end
end

local get_HB_variant_char
if harfbuzz then
  local dir_ltr = harfbuzz.Direction.new"ltr"
  local dir_ttb = harfbuzz.Direction.new"ttb"
  function get_HB_variant_char (fontdata, charcode, vertical)
    local hbfont = fontdata.hb.shared.font
    local spec   = fontdata.specification
    local shaper = spec.features.raw.shaper
    local buff   = harfbuzz.Buffer.new()
    buff:set_direction(vertical and dir_ttb or dir_ltr)
    buff:set_script(spec.script)
    buff:set_language(spec.language)
    buff:add_codepoints{charcode}
    hbfont:set_scale(fontdata.hb.hscale, fontdata.hb.vscale)
    harfbuzz.shape_full(hbfont, buff, spec.hb_features, shaper and {shaper} or {})
    local glyphs = buff:get_glyphs()
    if glyphs and glyphs[1] then
      if charcode == 32 then
        return glyphs[1].x_advance, glyphs[1].y_advance
      end
      return glyphs[1].codepoint + fontdata.hb.shared.gid_offset
    end
  end
end

local process_vertical_font
do
  local get_tsb_table_harf, get_tsb_table_node
  if harfbuzz then
    local vmtxtag = harfbuzz.Tag.new"vmtx"
    local vheatag = harfbuzz.Tag.new"vhea"
    function get_tsb_table_harf (fontdata)
      local tsb_font_data = fontoptions.tsb_data or {}
      local hbface = fontdata.hb.shared.face
      local key = tostring(hbface)
      if tsb_font_data[key] then
        return tsb_font_data[key]
      end
      local vmtx_b = hbface:get_table(vmtxtag)
      local vhea_b = hbface:get_table(vheatag)
      if vmtx_b:get_length() > 0 and vhea_b:get_length() > 35 then
        local numofglyphs = hbface:get_glyph_count()
        local data = vhea_b:get_data()
        local numofheights = (">H"):unpack(data, 35)
        data = vmtx_b:get_data()
        local vmtx, pos, ht, tsb = { }, 1
        for i = 0, numofglyphs-1 do
          if i < numofheights then
            ht, pos = (">H"):unpack(data, pos)
          end
          tsb, pos = (">h"):unpack(data, pos)
          vmtx[i] = { ht = ht, tsb = tsb }
        end
        tsb_font_data[key] = vmtx
        return vmtx
      end
    end
  end
  do
    local streamreader = utilities.files
    local openfile     = streamreader.open
    local closefile    = streamreader.close
    local readstring   = streamreader.readstring
    local readulong    = streamreader.readcardinal4
    local readushort   = streamreader.readcardinal2
    local readfixed    = streamreader.readfixed4
    local readshort    = streamreader.readinteger2
    local setpos       = streamreader.setposition

    local function get_otf_tables (f, subfont)
      if f then
        local sfntversion = readstring(f,4)
        if sfntversion == "ttcf" then
          local ttcversion = readfixed(f)
          local numfonts   = readulong(f)
          if subfont >= 1 and subfont <= numfonts then
            local offsets = {}
            for i = 1, numfonts do
              offsets[i] = readulong(f)
            end
            setpos(f, offsets[subfont])
            sfntversion = readstring(f,4)
          end
        end
        if sfntversion == "OTTO" or sfntversion == "true" or sfntversion == "\0\1\0\0" then
          local numtables     = readushort(f)
          local searchrange   = readushort(f)
          local entryselector = readushort(f)
          local rangeshift    = readushort(f)
          local tables        = {}
          for i= 1, numtables do
            local tag = readstring(f,4)
            tables[tag] = {
              checksum = readulong(f),
              offset   = readulong(f),
              length   = readulong(f),
            }
          end
          return tables
        end
      end
    end

    local function read_maxp (f, t)
      if f and t then
        setpos(f, t.offset)
        return {
          version   = readfixed(f),
          numglyphs = readushort(f),
        }
      end
    end

    local function read_vhea (f, t)
      if f and t then
        setpos(f, t.offset)
        return {
          version               = readfixed(f),
          ascent                = readshort(f),
          descent               = readshort(f),
          lineGap               = readshort(f),
          advanceheightmax      = readshort(f),
          mintopsidebearing     = readshort(f),
          minbottomsidebrearing = readshort(f),
          ymaxextent            = readshort(f),
          caretsloperise        = readshort(f),
          caretsloperun         = readshort(f),
          caretoffset           = readshort(f),
          reserved1             = readshort(f),
          reserved2             = readshort(f),
          reserved3             = readshort(f),
          reserved4             = readshort(f),
          metricdataformat      = readshort(f),
          numheights            = readushort(f),
        }
      end
    end

    local function read_vmtx (f, t, numofheights, numofglyphs)
      if f and t and numofheights and numofglyphs then
        setpos(f, t.offset)
        local vmtx = {}
        local height = 0
        for i = 0, numofheights-1 do
          height = readushort(f)
          vmtx[i] = {
            ht  = height,
            tsb = readshort(f),
          }
        end
        for i = numofheights, numofglyphs-1 do
          vmtx[i] = {
            ht  = height,
            tsb = readshort(f),
          }
        end
        return vmtx
      end
    end

    function get_tsb_table_node (fontdata)
      local tsb_font_data = fontoptions.tsb_data or {}
      local filename = fontdata.specification.filename or fontdata.filename
      local subfont = tonumber(fontdata.subfont) or 1
      local key = ("%s::%s"):format(filename, subfont)
      if tsb_font_data[key] then
        return tsb_font_data[key]
      end
      local f = openfile(filename, true) -- true: zero-based
      if f then
        local vmtx
        local tables = get_otf_tables(f, subfont)
        if tables then
          local vhea = read_vhea(f, tables.vhea)
          local numofheights = vhea and vhea.numheights
          local maxp = read_maxp(f, tables.maxp)
          local numofglyphs = maxp and maxp.numglyphs
          vmtx = read_vmtx(f, tables.vmtx, numofheights, numofglyphs)
        end
        closefile(f)
        tsb_font_data[key] = vmtx
        return vmtx
      end
    end
  end

  local function fontdata_warning(activename, ...)
    if not active_processes[activename] then
      warning(...)
      active_processes[activename] = true
    end
  end

  local dfltfntsize = get_font_param(font.current(), "quad") or 655360

  function process_vertical_font (fontdata)
    local fullname = fontdata.fullname

    if not fontdata.hb and fontdata.type == "virtual" then
      fontdata_warning("vitrual."..fullname,
      "Virtual font `%s' cannot be\nused for vertical writing.", fullname)
      return
    end

    local tsb_tab = fontdata.hb and get_tsb_table_harf(fontdata) or get_tsb_table_node(fontdata)

    if not tsb_tab then
      fontdata_warning("vertical."..fullname,
      "Vertical metrics table (vmtx) not found in the font\n`%s'", fullname)
      return
    end

    local extend  = (fontdata.extend  or 1000)/1000
    local squeeze = (fontdata.squeeze or 1000)/1000

    local shared       = fontdata.shared or {}
    local descriptions = shared.rawdata and shared.rawdata.descriptions or {}
    local parameters   = fontdata.parameters or {}
    local scale    = fontdata.hb and fontdata.hb.scale or parameters.factor or 655.36
    local quad     = parameters.quad or 655360
    local xheight  = parameters.x_height and parameters.x_height / squeeze * extend or quad/2
    local ascender = fontdata.hb and get_asc_desc(fontdata.hb) or parameters.ascender or quad*0.8

    local goffset = xheight/2 * (dfltfntsize / quad) -- TODO?

    -- declare shift amount of horizontal box inside vertical env.
    if fontdata.vertcharraise then return end -- avoid multiple running
    fontdata.vertcharraise = goffset

    local charraise
    if fontdata.hb then
      charraise = font_opt_dim(fontdata, "charraise") or 0
      fontdata.luatexko_charraise = charraise -- for luamplib
    end

    for i,v in pairs(fontdata.characters) do
      local voff = goffset - (v.width or 0)/2
      local gid  = v.index
      local bbox = fontdata.hb and get_hb_char_bbox(fontdata, gid)
            or descriptions[i] and descriptions[i].boundingbox or {0,0,0,0}
      local tsb  = tsb_tab[gid] and tsb_tab[gid].tsb
      local hoff = tsb and (bbox[4] + tsb) * scale * squeeze or ascender

      if fontdata.hb then
        v.luatexko_voff = voff
        v.luatexko_hoff = hoff
      else
        v.commands = {
          { "down", -voff },
          { "right", hoff },
          { "pdf", "q 0 1 -1 0 0 0 cm" },
          { "push" },
          { "char", i },
          { "pop" },
          { "pdf", "Q" },
        }
      end

      local vw = tsb_tab[gid] and tsb_tab[gid].ht
      vw = vw and vw * scale * squeeze or quad

      -- character width shall be consistent with the width in the font program
      local diff = vw - v.width
      if diff and diff ~= 0 then
        v.luatexko_diff = diff
      end

      local ht = bbox[3] * scale * extend + voff
      local dp = bbox[1] * scale * extend + voff
      if fontdata.hb then
        ht, dp = ht + charraise, dp + charraise
      end
      v.height = ht > 0 and  ht or 0
      v.depth  = dp < 0 and -dp or 0
    end
    local spacechar = char_in_font(fontdata, 32)
    if spacechar then
      local wd = spacechar.width or parameters.space
      wd = wd + (spacechar.luatexko_diff or 0)
      parameters.space         = wd
      parameters.space_stretch = wd/2
      parameters.space_shrink  = wd/2
    end
    parameters.ascender  = quad/2 + goffset
    parameters.descender = quad/2 - goffset

    local fea = shared.features or {}
    fea.kern = nil  -- only for horizontal writing
    fea.vert = true -- should be activated by default

    if fontdata.hb then
      local hb_features, t = fontdata.specification.hb_features or { }, { }
      for i,v in ipairs(hb_features) do
        t[tostring(v)] = i
      end
      if t.kern then table.remove(hb_features, t.kern) end
      if not t.vert then hb_features[#hb_features+1] = harfbuzz.Feature.new"vert"  end
      -- now reset hb.space and parameters.space: see also the otfregister.features below
      local spacewidth     = get_HB_variant_char(fontdata, 32)
      local _, spaceheight = get_HB_variant_char(fontdata, 32, true) -- ttb
      if spacewidth and spaceheight then
        fontdata.hb.space = spacewidth * scale
        fontdata.parameters.space = -spaceheight * scale
      end
      --
      if t.vhal then -- harf-mode vhal feature not working properly with luatexko, so an alternative
        table.remove(hb_features, t.vhal)
        fea.vhal = nil
        fea.compresspunctuations = true
        activate_process("post_shaping_filter", process_glyph_width, "compresspunctuations")
      end
      return
    end

    local res = fontdata.resources or {}
    local seq = res.sequences or {}
    for _,v in ipairs(seq) do
      local fea = v.features or {}
      if fea.vhal or fea.vkrn or fea.valt or fea.vpal or fea.vert then
        if v.type == "gpos_single" then
          for _,vv in pairs(v.steps or {}) do
            for _,vvv in pairs(vv.coverage or {}) do
              if type(vvv) == "table" and #vvv == 4 then
                vvv[1], vvv[2], vvv[3], vvv[4], vvv[5] =
                -vvv[2], vvv[1], vvv[4], vvv[3], 0 -- last 0 to avoid multiple run
              end
            end
          end
        elseif v.type == "gpos_pair" then
          for _,vv in pairs(v.steps or {}) do
            for _,vvv in pairs(vv.coverage or {}) do
              for _,vvvv in pairs(vvv) do
                for _,vvvvv in pairs(vvvv) do
                  if type(vvvvv) == "table" and #vvvvv == 4 then
                    vvvvv[1], vvvvv[2], vvvvv[3], vvvvv[4], vvvvv[5] =
                    -vvvvv[2], vvvvv[1], vvvvv[4], vvvvv[3], 0
                  end
                end
              end
            end
          end
        end
      end
    end
  end
end

local process_vertical_diff
do
  local iwspaceattributeid = luatexbase.attributes.g__tag_interwordspace_attr
  function process_vertical_diff (head)
    local curr = head
    while curr do
      if curr.id == glyphid
        and fontoptions.is_vertical[curr.font]
        and not has_attribute(curr, verticalattr) then

        set_attribute(curr, verticalattr, 1)
        local chardata = char_in_font(curr.font, curr.char)
        local diff = chardata and chardata.luatexko_diff or 0

        if chardata and has_harf_data(curr.font) then -- harf-mode
          local charraise = fontoptions.charraise[curr.font] or 0
          local yofforig  = curr.yoffset - charraise
          diff = diff + curr.width - yofforig
          curr.yoffset = yofforig - (chardata.luatexko_hoff or 0)
          curr.xoffset = curr.xoffset + (chardata.luatexko_voff or 0) + charraise
          local save = nodenew(whatsitid, save_whatsit)
          local matrix = nodenew(whatsitid, matrix_whatsit)
          matrix.data = "0 1 -1 0"
          local restore = nodenew(whatsitid, restore_whatsit)
          save.attr, matrix.attr, restore.attr = curr.attr, curr.attr, curr.attr
          set_attribute(save, charhead, 1)
          set_attribute(matrix, charhead, 2)
          head = insert_before(head, curr, save)
          head = insert_before(head, curr, matrix)
          if curr.height ~= 0 then
            local rule = nodenew(ruleid)
            rule.height, rule.depth, rule.width = curr.height, 0, 0
            rule.subtype, rule.attr = 3, curr.attr
            set_attribute(rule, charhead, 3)
            head = insert_before(head, curr, rule)
          end
          if curr.width ~= 0 then
            local kern = nodenew(kernid)
            kern.kern = -curr.width
            head, curr = insert_after(head, curr, kern)
          end
          head, curr = insert_after(head, curr, restore)
          if iwspaceattributeid then -- for tagging fakespace
            local n = getnext(curr)
            if n and n.id == glueid and n.subtype == 13 then -- spaceskip
              set_attribute(n, iwspaceattributeid, 1)
            end
          end
        end

        if tex.sp(diff) ~= 0 then
          local k = nodenew(kernid)
          k.kern = diff
          head, curr = insert_after(head, curr, k)
        end
      end
      curr = getnext(curr)
    end
    return head
  end
end

function luatexko.gethorizboxmoveright ()
  for _, v in ipairs{ font.current(),
                      tex.attribute.luatexkohangulfontattr,
                      tex.attribute.luatexkohanjafontattr,
                      tex.attribute.luatexkofallbackfontattr } do
    if v and v > 0 then
      local amount = fontoptions.vertcharraise[v]
      if amount then
        amount = amount + (fontoptions.charraise[v] or 0)
        token.set_macro("luatexkohorizboxmoveright", tex.sp(amount).."sp")
        break
      end
    end
  end
end

-- charraise

local function process_charraise (head)
  local curr = head
  while curr do
    local id = curr.id
    if id == glyphid then
      if not has_attribute(curr,charraiseattr) then
        local raise = fontoptions.charraise[curr.font]
        if raise then
          curr.yoffset = (curr.yoffset or 0) + raise
        end
        set_attribute(curr, charraiseattr, 1)
      end
    elseif id == discid then
      process_charraise(curr.pre)
      process_charraise(curr.post)
      process_charraise(curr.replace)
    end
    curr = getnext(curr)
  end
  return head
end

-- fake italic correctioin

local process_fake_slant_corr
do
  local function harf_reordered_tonemark (curr)
    if has_harf_data(curr.font) then
      local props = getproperty(curr) or {}
      local actualtext = props.luaotfload_startactualtext
      return actualtext and actualtext:find"302[EF]$"
    end
  end
  function process_fake_slant_corr (head) -- for font fallback
    local curr = head
    while curr do
      local id = curr.id
      if id == kernid then
        if curr.subtype == 3 and curr.kern == 0 then -- italcorr
          local p, t = getprev(curr), {}
          while p do
            if p.id == glyphid and not fontoptions.is_vertical[p.font] then
              -- harf font: break before reordered tone mark
              if harf_reordered_tonemark(p) then
                break
              end

              local slant = fontoptions.slantvalue[p.font]
              if slant and slant > 0 then
                t[#t+1] = char_in_font(p.font, p.char).italic or 0
              end

              local c = has_attribute(p, unicodeattr) or p.char
              if is_jungsong(c) or is_jongsong(c) or hangul_tonemark[c] then
              else
                break
              end
            elseif p.id == whatsitid then
            else
              break
            end
            p = getprev(p)
          end

          if p.id == glyphid and #t > 0 then
            local italic = math.max(table.unpack(t))
            if italic > 0 then
              curr.kern = italic
            end
          end
        end
      elseif id == mathid then
        curr = end_of_math(curr)
      end
      curr = getnext(curr)
    end
    return head
  end
end

local function process_fake_slant_font (fontdata, fsl)
  if fsl and fsl > 0 then
    fontdata.slant = fsl*1000

    local params = fontdata.parameters or {}
    params.slant = (params.slant or 0) + fsl*65536 -- slant per point

    local hb = has_harf_data(fontdata)
    local scale  = hb and hb.scale or params.factor or 655.36
    local shared = fontdata.shared or {}
    local descrs = shared.rawdata and shared.rawdata.descriptions or {}

    for i, v in pairs(fontdata.characters) do
      local ht   = v.height and v.height > 0 and v.height or 0
      local wd   = v.width  and v.width  > 0 and v.width  or 0
      local rbearing = 0

      if wd > 0 then -- or, jong/jung italic could by very large value
        local bbox = hb and get_hb_char_bbox(fontdata, v.index)
                  or descrs[i] and descrs[i].boundingbox
        if bbox then
          rbearing = wd - bbox[3]*scale
        end
      end

      local italic = ht * fsl - rbearing
      if italic > 0 then
        v.italic = italic
      end
    end
  end
end

-- AC00 11A8, AC00 11F0 ...
-- these should not happen in KS-observing documents
-- but HarfBuzz supports these sequences anyway.
local function normalize_syllable_TC (head)
  local curr = head
  while curr do
    if curr.id == glyphid and not has_harf_data(curr.font) then
      local c, f = curr.char, curr.font
      if is_hangul(c) and (c - 0xAC00) % 28 == 0 then
        local t = getnext(curr)
        if t then
          if t.id == glyphid then
            local tc, tf = t.char, t.font
            if is_jongsong(tc) and f == tf then
              if tc <= 0x11C2 then
                curr.char = c + tc - 0x11A7
                noderemove(head, t)
                nodefree(t)
              else
                c = (c - 0xAC00) // 28
                curr.char = c // 21 + 0x1100
                local v = nodecopy(curr)
                v.char = c % 21 + 0x1161
                insert_after(head, curr, v)
                curr = t
              end
            end
          else
            curr = t
          end
        end
      end
    end
    curr = getnext(curr)
  end
end

-- wrap up

luatexbase.add_to_callback ("hyphenate",
function(head)
  normalize_syllable_TC(head)
  process_fonts(head)
  lang.hyphenate(head)
end,
"luatexko.hyphenate.fonts_and_languages")

luatexbase.create_callback("luatexko_prelinebreak_first",  "data", function(...) return ... end)
luatexbase.create_callback("luatexko_prelinebreak_second", "data", function(...) return ... end)
luatexbase.create_callback("luatexko_prelinebreak_third",  "data", function(...) return ... end)

luatexbase.add_to_callback("pre_shaping_filter", function(h)
  local par = h.id == localparid
  h = luatexbase.call_callback("luatexko_prelinebreak_first", h, par)
  h = process_cjk_punctuation_spacing(h, par)
  h = luatexbase.call_callback("luatexko_prelinebreak_second", h, par)
  h = process_linebreak(h, par)
  h = luatexbase.call_callback("luatexko_prelinebreak_third", h, par)
  return h
end, "luatexko.pre_shaping_filter")

local otfregister = fonts.constructors.features.otf.register

otfregister {
  name = "removeclassicspaces",
  description = "remove spaces in classic typesetting",
  default = false,
  manipulators = {
    node = function()
      activate_process("luatexko_prelinebreak_first", process_remove_spaces, "removeclassicspaces")
    end,
    plug = function()
      activate_process("luatexko_prelinebreak_first", process_remove_spaces, "removeclassicspaces")
    end,
  },
}

otfregister {
  name = "interhangul",
  description = "insert more glue between Hangul chars",
  default = false,
  manipulators = {
    node = function()
      activate_process("luatexko_prelinebreak_second", process_interhangul, "interhangul")
    end,
    plug = function()
      activate_process("luatexko_prelinebreak_second", process_interhangul, "interhangul")
    end,
  },
}

otfregister {
  name = "interlatincjk",
  description = "insert glue between CJK and Latin",
  default = false,
  manipulators = {
    node = function()
      activate_process("luatexko_prelinebreak_second", process_interlatincjk, "interlatincjk")
    end,
    plug = function()
      activate_process("luatexko_prelinebreak_second", process_interlatincjk, "interlatincjk")
    end,
  },
}

otfregister {
  name = "charraise",
  description = "raise chars",
  default = false,
  manipulators = {
    node = function()
      if not active_processes.charraise then
        charraiseattr = luatexbase.new_attribute"luatexko_char_raise_attr"
      end
      activate_process("post_shaping_filter", process_charraise, "charraise")
    end,
    plug = function()
      if not active_processes.charraise then
        charraiseattr = luatexbase.new_attribute"luatexko_char_raise_attr"
      end
      activate_process("post_shaping_filter", process_charraise, "charraise")
    end,
  },
}

otfregister {
  name = "compresspunctuations",
  description = "compress width of CJK punctuations",
  default = false,
  manipulators = {
    node = function()
      activate_process("post_shaping_filter", process_glyph_width, "compresspunctuations")
    end,
    plug = function()
      activate_process("post_shaping_filter", process_glyph_width, "compresspunctuations")
    end,
  },
}

otfregister {
  name = "slant",
  description = "fake slant fallback fonts",
  default = false,
  manipulators = {
    node = function(fontdata, _, value)
      process_fake_slant_font(fontdata, value)
      activate_process("post_shaping_filter", process_fake_slant_corr, "slant")
    end,
    plug = function(fontdata, _, value)
      process_fake_slant_font(fontdata, value)
      activate_process("post_shaping_filter", process_fake_slant_corr, "slant")
    end,
  },
}

otfregister {
  name = "vertical",
  description = "vertical typesetting",
  default = false,
  manipulators = {
    node = function(fontdata)
      if not active_processes.verticalwriting then
        verticalattr = luatexbase.new_attribute"luatexko_vertical_attr"
      end
      process_vertical_font(fontdata)
      activate_process("post_shaping_filter", process_vertical_diff, "verticalwriting", 1)
    end,
    plug = function(fontdata) -- experimental
      if not active_processes.verticalwriting then
        verticalattr = luatexbase.new_attribute"luatexko_vertical_attr"
      end
      process_vertical_font(fontdata)
      activate_process("post_shaping_filter", process_vertical_diff, "verticalwriting", 1)
    end,
  },
}

otfregister {
  name = "expansion",
  description = "glyph expansion",
  default = false,
  manipulators = {
    node = function()
      if tex.adjustspacing == 0 then
        tex.set("global", "adjustspacing", 2)
      end
    end,
    plug = function(fontdata, _, value)
      local setup = fonts.expansions.setups[value] or {}
      fontdata.stretch = fontdata.stretch or (setup.stretch or 2)*10
      fontdata.shrink  = fontdata.shrink  or (setup.shrink  or 2)*10
      fontdata.step    = fontdata.step    or (setup.step    or .5)*10
      if tex.adjustspacing == 0 then
        tex.set("global", "adjustspacing", 2)
      end
    end,
  },
}

fonts.protrusions.setups.default[0xFF0C] = { 0, 1 }
fonts.protrusions.setups.default[0xFF0E] = { 0, 1 }
fonts.protrusions.setups.default[0xFF1A] = { 0, 1 }
fonts.protrusions.setups.default[0xFF1B] = { 0, 1 }

otfregister {
  name = "protrusion",
  description = "glyph protrusion",
  default = false,
  manipulators = {
    node = function(fontdata, _, value)
      local setup = fonts.protrusions.setups[value] or {}
      local quad  = fontdata.parameters.quad
      local left,right,factor = setup.left or 1, setup.right or 1, setup.factor or 1
      for i, v in pairs(fontdata.characters) do
        local uni = v.unicode
        if uni then
          local lr = setup[uni]
          if lr then
            local wdq = (v.width + (v.luatexko_diff or 0))/quad*1000
            local l, r = lr[1], lr[2]
            if l and l ~= 0 then v.left_protruding  = wdq*l*left*factor end
            if r and r ~= 0 then v.right_protruding = wdq*r*right*factor end
          end
        end
      end
      if tex.protrudechars == 0 then
        tex.set("global", "protrudechars", 2)
      end
    end,
    plug = function(fontdata, _, value)
      local setup = fonts.protrusions.setups[value] or {}
      local quad  = fontdata.parameters.quad
      local chrs  = fontdata.characters
      local left,right,factor = setup.left or 1, setup.right or 1, setup.factor or 1
      for i, v in pairs(setup) do
        if chrs[i] then
          local l, r = v[1], v[2]
          for _, ii in ipairs{i, get_HB_variant_char(fontdata,i)} do
            local chr = chrs[ii]
            if chr then
              local wdq = (chr.width + (chr.luatexko_diff or 0))/quad*1000
              if l and l ~= 0 then chr.left_protruding  = wdq*l*left*factor end
              if r and r ~= 0 then chr.right_protruding = wdq*r*right*factor end
            end
          end
        end
      end
      if tex.protrudechars == 0 then
        tex.set("global", "protrudechars", 2)
      end
    end,
  },
}

-- reset hb.space and parameters.space :
-- with \spacekip and noto cjk fonts, harf mode yields different glue width from the node mode.
-- as harf mode checks if space x_advance matches hb.space, and, if not, adjusts the space glue.
-- the fix below is to avoid this ajdustment.
otfregister {
  name = "features",
  description = "patch harf-mode hb.space",
  default = false,
  manipulators = {
    plug = function(fontdata, _, value)
      if value == "harf"
        and option_in_font(fontdata, "script") == "hang"
        and not option_in_font(fontdata, "vertical") -- already done
        then
        local spacewidth = get_HB_variant_char(fontdata, 32)
        if spacewidth then
          fontdata.hb.space = spacewidth * fontdata.hb.scale
          fontdata.parameters.space = fontdata.hb.space
        end
      end
    end,
  },
}

do
  local auxiliary_procs = {
    dotemph = {
      post_linebreak_filter = process_dotemph,
      hpack_filter          = process_dotemph,
    },
    uline   = {
      post_linebreak_filter = function(h) return process_uline(h) end,
      hpack_filter          = function(h) return process_uline(h) end,
    },
    ruby    = {
      pre_linebreak_filter = process_ruby_pre_linebreak,
      hpack_filter         = process_ruby_pre_linebreak,
    },
    autojosa = {
      luatexko_prelinebreak_first = process_josa,
    },
    reorderTM = {
      luatexko_prelinebreak_third = process_reorder_tonemarks,
    },
  }

  -- dotemph 등을 수식 한글에서도 작동하게 하려면
  -- post_mlist_to_hlist_filter 콜백을 이용해야 한다.

  function luatexko.activate (name)
    for cbnam, cbfun in pairs( auxiliary_procs[name] ) do
      local myname = "luatexko." .. cbnam .. "." .. name
      if cbnam == "hpack_filter" then
        luatexbase.declare_callback_rule(cbnam, myname, "before", "luaotfload.color_handler")
      end
      luatexbase.add_to_callback(cbnam, cbfun, myname)
    end
    active_processes[name] = true
  end
end

-- aux functions

function luatexko.deactivateall (str)
  luatexko.deactivated = {}
  for _, name in ipairs{ "pre_shaping_filter",
                         "post_shaping_filter",
                         "pre_linebreak_filter",
                         "hpack_filter",
                         "hyphenate",
                         "post_linebreak_filter",
                       } do
    local t = {}
    for i, v in ipairs( luatexbase.callback_descriptions(name) ) do
      if v:find(str or "^luatexko%.") then
        local ff, dd = luatexbase.remove_from_callback(name, v)
        t[#t+1] = { ff, dd, i }
      end
    end
    luatexko.deactivated[name] = t
  end
end

function luatexko.reactivateall ()
  for name, v in pairs(luatexko.deactivated or {}) do
    for _, vv in ipairs(v) do
      luatexbase.add_to_callback(name, table.unpack(vv))
    end
  end
  luatexko.deactivated = nil
end

-- xxruby

local function get_unicode_graphemes (s)
  local t = { }
  local graphemes = require'lua-uni-graphemes'
  for _, _, c in graphemes.graphemes(s) do
    t[#t+1] = c
  end
  return t
end
local xxruby_index = luatexbase.new_luafunction"luatexko_xxruby_func"
lua.get_functions_table()[xxruby_index] = function ()
  local base = token.scan_argument()
  local ruby = token.scan_argument()
  local base_t = get_unicode_graphemes(base)
  local ruby_t = get_unicode_graphemes(ruby)
  local t = { }
  local function ruby_args_to_t (arg1, arg2)
    for _,v in ipairs{ token.create"ruby",
                       token.new(0,1), arg1, token.new(0,2),
                       token.new(0,1), arg2, token.new(0,2), } do
      t[#t+1] = v
    end
  end
  if #base_t == #ruby_t then
    for i=1, #base_t do
      ruby_args_to_t(base_t[i], ruby_t[i])
    end
  else
    warning"lengths of base/ruby characters should be identical.\nrevert to \\ruby."
    ruby_args_to_t(base, ruby)
  end
  tex.sprint(-2, t)
end
token.set_lua("xxruby", xxruby_index, "global", "protected")

local inhibitglue_index = luatexbase.new_luafunction"luatexko_inhibitglue_func"
token.set_lua("inhibitglue", inhibitglue_index, "global", "protected")
lua.get_functions_table()[inhibitglue_index] = function ()
  local icp, ics
  for _, v in ipairs {
    font.current(),
    tex.attribute.luatexkohangulfontattr,
    tex.attribute.luatexkohanjafontattr,
    tex.attribute.luatexkofallbackfontattr,
  }
  do
    icp = icp or fontoptions.intercharpenalty[v]
    ics = ics or fontoptions.intercharstretch[v]
    if icp and ics then break end
  end

  if tex.lastnodetype ~= 13 then -- penalty node
    icp = icp or 50
    if icp ~= 0 then
      local p = nodenew(penaltyid)
      p.penalty = icp
      set_attribute(p, inhibitglueattr, 1)
      nodewrite(p)
    end
  end

  ics = ics or stretch_f * fontoptions.en_size[font.current()]
  local g = nodenew(glueid)
  setglue(g, 0, ics, ics*0.6)
  if iwspaceOffattributeid then
    set_attribute(g, iwspaceOffattributeid, 1)
  end
  set_attribute(g, inhibitglueattr, 1)
  nodewrite(g)
end

