local V = require("texecole-vocab")
local P = require("texecole-prose")

local L = {}

local function trim(s) return (s:gsub("^%s+", ""):gsub("%s+$", "")) end

local function err(msg) error("texecole : " .. msg, 0) end

local function num_fr(s) return (s:gsub("(%d),(%d)", "%1.%2")) end

local MATH_KEYS_SORTED
local function math_keys()
  if not MATH_KEYS_SORTED then
    MATH_KEYS_SORTED = {}
    for k in pairs(V.MATH_WORDS) do MATH_KEYS_SORTED[#MATH_KEYS_SORTED+1] = k end
    table.sort(MATH_KEYS_SORTED, function(a, b) return #a > #b end)
  end
  return MATH_KEYS_SORTED
end

local CALC_SKIP = {
  ["et"] = true, ["ou"] = true, ["non"] = true,
  ["union"] = true, ["inter"] = true, ["intersection"] = true,
  ["dans"] = true, ["contient"] = true,
}

-- Deux jeux compilés une seule fois : les clés multi-jetons (espace, trait
-- d'union, apostrophe) restent dans une boucle appliquée du plus long au plus
-- court ; les clés mono-jeton sont traitées en UN seul balayage via une table
-- de correspondance, au lieu d'un gsub par clé.
local W_SET   = "%w_\128-\255"
local MATH_MULTI, MATH_SINGLE
local function math_build()
  if MATH_SINGLE then return end
  MATH_MULTI, MATH_SINGLE = {}, {}
  for _, k in ipairs(math_keys()) do          -- déjà trié du plus long au plus court
    if k:find("[^" .. W_SET .. "]") then      -- contient espace/tiret/apostrophe
      local esc = k:gsub("[%-%s']", "%%%0")
      MATH_MULTI[#MATH_MULTI + 1] = {
        k    = k,
        pat  = "%f[" .. W_SET .. "]" .. esc .. "%f[^" .. W_SET .. "]",
        repl = (V.MATH_WORDS[k]:gsub("%%", "%%%%")),
      }
    else
      MATH_SINGLE[k] = V.MATH_WORDS[k]
    end
  end
end

local function translate_math(expr, calc)
  math_build()
  expr = num_fr(expr)
  -- Vecteur nommé avec composantes, AVANT toute autre traduction :
  --   « vecteur u(3;5) »          -> vec(u) suivi des composantes en ligne
  --   « vecteur colonne u(3;5) »  -> vec(u) suivi des composantes en colonne
  -- La forme sans nom « vecteur(u) » (parenthèse collée) reste la flèche seule.
  expr = expr:gsub("vecteur%s+colonne%s+([%a_][%w_]*)%s*(%b())",
    function(nom, co) return "vec(" .. nom .. ") colvec" .. co end)
  expr = expr:gsub("vecteur%s+([%a_][%w_]*)%s*(%b())",
    function(nom, co) return "vec(" .. nom .. ") vector" .. co end)
  expr = expr:gsub("%s*;%s*", ", ")
  -- 1) clés multi-jetons d'abord (préséance du plus long)
  for _, e in ipairs(MATH_MULTI) do
    if not (calc and CALC_SKIP[e.k]) then
      expr = expr:gsub(e.pat, e.repl)
    end
  end
  -- 2) clés mono-jeton : un seul passage tokenisant
  expr = expr:gsub("%f[" .. W_SET .. "][" .. W_SET .. "]+%f[^" .. W_SET .. "]",
    function(tok)
      if calc and CALC_SKIP[tok] then return nil end
      return MATH_SINGLE[tok]
    end)
  return expr
end

local function translate_math_zones(line)
  line = line:gsub("%$(.-)%$", function(inner)
    return "$" .. translate_math(inner) .. "$"
  end)
  line = line:gsub("#(%b{})", function(braced)
    return "#{" .. translate_math(braced:sub(2, -2), true) .. "}"
  end)
  return line
end

-- Inscrit une fonction posée par <Soit> (via t.fn, singulier, ou t.fns,
-- pluriel factorisé) : même émission « <fn ...> » et même registre
-- L._fns dans les deux cas, pour que le reste du transpileur n'ait
-- jamais à distinguer les deux formes.
local function inscrire_fn(out, brut)
  local corps = translate_math(brut)
  out[#out + 1] = "<fn " .. corps .. ">"
  L._fns = L._fns or {}
  local fname = corps:match("^([%a_][%w_]*)%s*%(")
  if fname then L._fns[fname] = corps end
end

local function inscrire_fns(out, t)
  if t.fn then inscrire_fn(out, t.fn) end
  if t.fns then
    for _, brut in ipairs(t.fns) do inscrire_fn(out, brut) end
  end
end

local function translate_operators(cond)
  for fr, op in pairs(V.OPERATORS) do
    cond = cond:gsub("%f[%a]" .. fr:gsub(" ", "%%s+") .. "%f[%A]", op)
  end
  -- Connecteurs logiques du français : « et », « ou » (règle n°10 —
  -- chaque fois qu'un mot français existe, la prose gagne).
  cond = cond:gsub("%f[%a]et%f[%A]", "and")
  cond = cond:gsub("%f[%a]ou%f[%A]", "or")
  return num_fr(cond)
end

local function translate_control(line)
  local indp, vp, ap, bp, pp = line:match(
    "^(%s*)pour%s+([%a_][%w_]*)%s+de%s+(%S+)%s+à%s+(%S+)%s+avec%s+un%s+pas%s+de%s+(%S+)%s*{%s*$")
  if vp then
    return indp .. "for " .. vp .. " in " .. num_fr(ap) .. ".."
        .. num_fr(bp) .. " step " .. num_fr(pp) .. " {"
  end
  if line:match("^%s*pour%s+[%a_][%w_]*%s+de%s+%S+%s+à%s+%S+%s+par%s+pas%s+de%s+%S+%s*{%s*$") then
    err("le pas d'une boucle se dit « avec un pas de » : « pour i de "
      .. "0,5 à 2,5 avec un pas de 0,5 { ... } » — comme tout réglage, "
      .. "il s'introduit par « avec » (l'ancienne forme « par pas de » "
      .. "n'existe plus).")
  end

  local ind, v, a, b = line:match("^(%s*)pour%s+([%a_][%w_]*)%s+de%s+(%S+)%s+à%s+(%S+)%s*{%s*$")
  if v then return ind .. "for " .. v .. " in " .. num_fr(a) .. ".." .. num_fr(b) .. " {" end

  local ind2, v2, lst = line:match("^(%s*)pour%s+([%a_][%w_]*)%s+dans%s+(%b[])%s*{%s*$")
  if v2 then return ind2 .. "for " .. v2 .. " in " .. lst .. " {" end

  local ind3, cond = line:match("^(%s*)si%s+(.-)%s*{%s*$")
  if cond then return ind3 .. "if " .. translate_operators(cond) .. " {" end

  local ind3b, cond2 = line:match("^(%s*)}%s*sinon%s+si%s+(.-)%s*{%s*$")
  if cond2 then
    return ind3b .. "} elseif " .. translate_operators(cond2) .. " {"
  end

  if line:match("^%s*}%s*sinon%s*{%s*$") then
    return (line:gsub("sinon", "else"))
  end

  -- « tant que CONDITION faire { » — le « faire » est facultatif ; la
  -- condition est vérifiée avant chaque passage.
  local ind4, wc = line:match("^(%s*)tant%s+que%s+(.-)%s+faire%s*{%s*$")
  if not wc then
    ind4, wc = line:match("^(%s*)tant%s+que%s+(.-)%s*{%s*$")
  end
  if wc then return ind4 .. "while " .. translate_operators(wc) .. " {" end

  -- « faire { ... } tant que CONDITION » — le corps s'exécute au moins
  -- une fois, la condition est vérifiée après chaque passage.
  local ind6 = line:match("^(%s*)faire%s*{%s*$")
  if ind6 then return ind6 .. "repeat {" end
  local ind7, dc = line:match("^(%s*)}%s*tant%s+que%s+(.-)%s*$")
  if dc and not dc:match("{%s*$") then
    return ind7 .. "} until " .. translate_operators(dc)
  end

  local ind5, rest = line:match("^(%s*)soit%s+(.+)$")
  if rest then return ind5 .. "let " .. num_fr(rest) end

  return nil
end

local function translate_object_words(words, require_avec)
  words = trim(words or "")
  if words == "" then return "" end

  if require_avec then
    local placement, apres = words:match("^(%b[])%s*(.*)$")
    local prefixe = ""
    if placement then
      prefixe = placement .. " "
    else
      apres = words
    end
    apres = trim(apres)
    if apres ~= "" then
      local corps = apres:match("^avec%s+(.+)$")
      if not corps then
        err("liste d'attributs mal introduite : « " .. apres .. " ». "
          .. "Les attributs d'un objet s'ouvrent par « avec » : écrivez "
          .. "par exemple « " .. trim(prefixe) .. " avec bordures, entête "
          .. "et fond bleu Alice » — virgules entre les attributs, « et » "
          .. "avant le dernier.")
      end
      apres = corps
    end
    return translate_object_words(prefixe .. apres)
  end

  do
    local buf, depth = {}, 0
    local i, n = 1, #words
    while i <= n do
      local c = words:sub(i, i)
      if c == "{" or c == "[" or c == "(" then depth = depth + 1 end
      if c == "}" or c == "]" or c == ")" then depth = depth - 1 end
      if depth == 0 and words:sub(i, i + 3) == " et " then
        buf[#buf + 1] = ", "; i = i + 4
      else
        buf[#buf + 1] = c; i = i + 1
      end
    end
    words = table.concat(buf)
  end
  words = words:gsub("(entête en [%a%s'éèêàâîôûç%-]-),%s*(sur fond)", "%1 %2")
  words = words:gsub("(titre en [%a%s'éèêàâîôûç%-]-),%s*(sur fond)", "%1 %2")

  local out = {}

  words = words:gsub("%b[]", function(br)
    local inner = br:sub(2, -2)
    local nom, pospart = inner:match("^(.-)%s*:%s*(.+)$")
    local src = pospart or inner
    local codes, vert, horiz = {}, nil, nil
    if nom then codes[#codes + 1] = trim(nom) end
    local VNAT = { haut = "t", milieu = "m", bas = "b" }
    local HNAT = { gauche = "l", centre = "c", droite = "r" }
    for piece in src:gmatch("[^,]+") do
      local p = trim(piece)
      local nu = p:gsub("^en%s+", ""):gsub("^au%s+", ""):gsub("^à%s+", "")
      if VNAT[nu] then vert = VNAT[nu]
      elseif HNAT[nu] then horiz = HNAT[nu]
      elseif V.PLACE[nu] then codes[#codes + 1] = V.PLACE[nu]
      elseif V.PLACE[p] then codes[#codes + 1] = V.PLACE[p]
      else codes[#codes + 1] = p
      end
    end
    if vert or horiz then
      codes[#codes + 1] = (vert or "m") .. (horiz or "c")
    end
    return "[" .. table.concat(codes, ", ") .. "]"
  end)

  if words:match("%f[%w_\128-\255]bordée?%s+de%f[%W]") then
    err("« bordé de » n'existe plus : la couleur de bordure se dit avec "
      .. "le nom et son article — « une bordure bleue », « une bordure "
      .. "bleu marine » (règle n°11, l'attribut est un nom, pas un "
      .. "adjectif).")
  end

  for _, rule in ipairs(V.PROSE_ATTRS) do
    local lua_pat = rule._luapat
    if not lua_pat then
      lua_pat = rule.pat
        :gsub("[%%%-%(%)%.%+%*%?%[%]%^%$]", "%%%0")
        :gsub("«C»", "([%%a%%s'éèêàâîôûç%%-]+)")
        :gsub("«W»", "([%%a_][%%w_]*)")
        :gsub("«N»", "([%%d,%%.]+)")
        :gsub("«B»", "(%%b{})")
        :gsub("«BR»", "(%%b[])")
        :gsub("«PCT»", "%%%%")
      rule._luapat = lua_pat
    end
    words = words:gsub(lua_pat, function(c1, c2)
      local keys = type(rule.key) == "table" and rule.key or { rule.key }
      local caps = { c1, c2 }
      local frag = {}
      for i, key in ipairs(keys) do
        if rule.bare then
          frag[#frag + 1] = key
        else
        local v = rule.value or caps[i]
        if not rule.value then
          local raw = trim(v or "")
          local svg = V.color(raw)
          local reste = nil
          if not svg and rule.pat:find("«C»") then
            for _, ck in ipairs(V.color_keys_sorted()) do
              local suite = raw:match("^" .. ck:gsub("[%-']", "%%%0")
                                   .. "%f[^%w_\128-\255]%s*(.*)$")
              if suite then
                svg, reste = V.COLORS[ck], suite
                break
              end
            end
          end
          if svg then v = svg
          elseif raw:match("^[%d,%.]+$") then v = num_fr(raw)
          elseif raw:match("^%b{}$") then
            v = num_fr(raw):gsub("%s*;%s*", ", ")
          elseif raw:match("^%b[]$") then
            v = raw
          elseif rule.pat:find("«W»") and raw:match("^[%a_][%w_]*$") then
            v = raw
          else
            return nil
          end
        end
          frag[#frag + 1] = key .. ":" .. (v or "on")
          if reste and reste ~= "" then frag[#frag + 1] = reste end
        end
      end
      return table.concat(frag, " ")
    end)
  end

  words = words:gsub("données%s*(%b{})", function(b)
    local inner = b:sub(2, -2)
    if inner:find(":") then
      return "data:{" .. num_fr(inner):gsub("%s*;%s*", " | ") .. "}"
    end
    return "data:{" .. num_fr(inner):gsub("%s*;%s*", ", ") .. "}"
  end)
  words = words:gsub("pas%s+de%s+(%$.-%$)", function(m)
    return "step:{" .. m:sub(2, -2) .. "}"
  end)
  words = words:gsub("pas%s+de%s+([%d,%.]+)", function(n)
    return "step:" .. n:gsub(",", ".")
  end)
  words = words:gsub("([%d,%.]+)%s*mm%s+sur%s+([%d,%.]+)%s*mm",
    function(a, b) return a:gsub(",", ".") .. "x" .. b:gsub(",", ".") end)

  words = words:gsub("(%d+)%s+(tabulations?)%f[^%w]", "%1%2")
  words = words:gsub("(%d+)%s+(lignes?)%f[^%w]", "%1%2")

  words = words:gsub("%f[%w_]exposant%s+(%d+)mm", "up%1")
  words = words:gsub("%f[%w_]indice%s+(%d+)mm",   "down%1")
  words = words:gsub("%f[%w_]exposant%f[^%w_]", "up2")
  words = words:gsub("%f[%w_]indice%f[^%w_]",   "down1")

  local mw = {}
  for k in pairs(V.STYLE_WORDS) do
    if k:find(" ") then mw[#mw+1] = k end
  end
  table.sort(mw, function(a, b) return #a > #b end)
  for _, k in ipairs(mw) do
    local w = "%w_\128-\255"
    local pat = "%f[" .. w .. "]" .. k:gsub("[%-%s']", "%%%0")
             .. "%f[^" .. w .. "]"
    words = words:gsub(pat, V.STYLE_WORDS[k])
  end

  do
    local cleaned, d = {}, 0
    for i = 1, #words do
      local ch = words:sub(i, i)
      if ch == "{" or ch == "[" then d = d + 1 end
      if ch == "}" or ch == "]" then d = d - 1 end
      if ch == "," and d == 0 then ch = " " end
      cleaned[#cleaned + 1] = ch
    end
    words = table.concat(cleaned):gsub("%f[%w_]et%f[^%w_]", " ")
    for _, mot in ipairs({ "avec", "les", "des", "un", "une", "le", "la" }) do
      words = words:gsub("%f[%w_]" .. mot .. "%f[^%w_]", " ")
    end
    words = words:gsub("%f[%w_]l'", "")
  end

  local tokens, buf, depth = {}, {}, 0
  for i = 1, #words do
    local ch = words:sub(i, i)
    if ch == "{" then depth = depth + 1 end
    if ch == "}" then depth = depth - 1 end
    if depth == 0 and ch:match("%s") then
      if #buf > 0 then tokens[#tokens+1] = table.concat(buf); buf = {} end
    else
      buf[#buf+1] = ch
    end
  end
  if #buf > 0 then tokens[#tokens+1] = table.concat(buf) end
  for _, word in ipairs(tokens) do
    local done = false

    local k, v = word:match("^([%wéèê_]+):(.+)$")
    if k then
      v = v:gsub("^%$(.-)%$$", "{%1}")
      out[#out + 1] = (V.KV_KEYS[k] or k) .. ":" .. num_fr(v)
      done = true
    end

    if not done then
      local n, radical = word:match("^(%d+)([%aé]+)$")
      if n and V.COUNTED_WORDS[radical] then
        out[#out + 1] = n .. V.COUNTED_WORDS[radical]
        done = true
      end
    end

    if not done and V.STYLE_WORDS[word] then
      out[#out + 1] = V.STYLE_WORDS[word]; done = true
    end

    if not done then
      out[#out + 1] = word
    end
  end

  local joined = table.concat(out, " ")
  local shelters = {}
  joined = joined:gsub("%b{}", function(g)
    shelters[#shelters + 1] = g
    return "\1" .. #shelters .. "\1"
  end)
  for _, k in ipairs(V.color_keys_sorted()) do
    local w = "%w_\128-\255"
    local pat = "%f[" .. w .. "]" .. k:gsub("[%-']", "%%%0")
             .. "%f[^" .. w .. "]"
    joined = joined:gsub(pat, V.COLORS[k])
  end
  joined = joined:gsub("\1(%d+)\1", function(n)
    return shelters[tonumber(n)]
  end)
  return joined
end

local ENGLISH_TAGS = {
  table = "tableau", box = "cadre", list = "liste", img = "image",
  grid = "grille", area = "zone", matrix = "matrice", bmatrix = "matrice entre crochets",
  system = "système", vartab = "tableau de variations",
  signtab = "tableau de signes", numberline = "droite graduée",
  tree = "arbre", stats = "statistique", plot = "Trace la fonction ...",
  draw = "Trace ...", fn = "Soit une fonction ...",
  section = "section", subsection = "sous-section", toc = "sommaire",
}

local TAGS_KEYS_SORTED
local function tags_keys()
  if not TAGS_KEYS_SORTED then
    TAGS_KEYS_SORTED = {}
    for k in pairs(V.TAGS) do TAGS_KEYS_SORTED[#TAGS_KEYS_SORTED+1] = k end
    table.sort(TAGS_KEYS_SORTED, function(a, b) return #a > #b end)
  end
  return TAGS_KEYS_SORTED
end

local function translate_tag_head(head)
  for _, v in ipairs(V.VERBES_OBJET) do
    local rest = head:match("^" .. v .. "%s+(.*)$")
    if rest then head = rest; break end
  end
  head = head:gsub("^[Uu]ne?%s+", ""):gsub("^[Ll]es?%s+", "")
             :gsub("^[Dd]es%s+", ""):gsub("^[Ll]a%s+", "")
             :gsub("^[Ll]'", "")
  local first = head:match("^([%a_][%w_]*)")
  if first and ENGLISH_TAGS[first] then
    local francais = false
    for _, k in ipairs(tags_keys()) do
      if head:find("^" .. k:gsub("[%-%s']", "%%%0") .. "%f[^%w_\128-\255]")
         or head == k then
        francais = true; break
      end
    end
    if not francais then
      error("texecole : « <" .. first .. " » est du scholatex. En texecole, "
        .. "on écrit « <" .. ENGLISH_TAGS[first] .. " ... » — une seule "
        .. "façon de dire chaque chose.", 0)
    end
  end
  local lst = head:match("^liste%s*(.*)$")
  if lst ~= nil then
    local style, rest = nil, lst
    local k, v, r = V.longest_match(V.LIST_STYLES, trim(lst))
    if k then style, rest = v, r end
    local words = translate_object_words(rest, true)
    return "list" .. (style and (":" .. style) or "") ..
           (words ~= "" and (" " .. words) or "")
  end

  local st = head:match("^statistique%s+en%s+(.*)$")
           or head:match("^statistique%s+(.*)$")
  if st then
    local k, v, r = V.longest_match(V.STATS_KINDS, trim(st))
    if k then
      return "stats kind:" .. v .. " " .. translate_object_words(r, true)
    end
    return "stats " .. translate_object_words(st, true)
  end

  local vt, vkv = head:match("^tableau de variations%s+de%s+([%a_][%w_]*)%s*,?%s*(.*)$")
  if vt then
    vkv = trim(vkv or "")
    if vkv ~= "" then
      local def = L._fns and L._fns[vt]
      if not def then
        error("texecole : le tableau de variations de « " .. vt
          .. " » avec rangées demande que la fonction ait été posée "
          .. "par <Soit>une fonction " .. vt .. "(x) = ... auparavant.", 0)
      end
      return "fn " .. def .. "\n            "
          .. translate_object_words(translate_math(vkv))
          .. ">\n<vartab " .. vt
    end
    return "vartab " .. vt
  end
  local sg = head:match("^tableau de signes%s+de%s+(%S+)%s*$")
  if sg then return "signtab " .. sg end
  local sgb = head:match("^tableau de signes%s+(.+)$")
  if sgb then return "signtab " .. translate_math(sgb) end

  local k, spec, rest = V.longest_match(V.TAGS, head)
  if k then
    local words = translate_object_words(rest, true)
    return spec.name .. (words ~= "" and (" " .. words) or "")
  end

  return translate_object_words(head)
end

local CALC_NOUNS = {
  ["la dérivée seconde de"] = "Dérivée seconde de",
  ["la dérivée de"]         = "Dérivée de",
  ["la primitive de"]       = "Primitive de",
  ["les zéros de"]          = "Zéros de",
  ["les racines carrées de"]    = "Racines carrées de",
  ["les racines cubiques de"]   = "Racines cubiques de",
  ["les racines quatrièmes de"] = "Racines quatrièmes de",
  ["les racines cinquièmes de"] = "Racines cinquièmes de",
  ["les racines sixièmes de"]   = "Racines sixièmes de",
  ["les racines septièmes de"]  = "Racines septièmes de",
  ["les racines huitièmes de"]  = "Racines huitièmes de",
  ["les racines neuvièmes de"]  = "Racines neuvièmes de",
  ["les racines dixièmes de"]   = "Racines dixièmes de",
  ["les racines onzièmes de"]   = "Racines onzièmes de",
  ["les racines douzièmes de"]  = "Racines douzièmes de",
  ["la limite de"]          = "Limite de",
  ["le développement limité de"] = "Développement limité de",
  ["la forme canonique de"] = "Forme canonique de",
  ["l'intégrale numérique de"] = "Intégrale numérique de",
  ["les valeurs propres de"] = "Valeurs propres de",
  ["le déterminant de"]     = "Déterminant de",
  ["l'inverse de"]          = "Inverse de",
  ["la somme de"]           = "Somme de",
  ["le produit de"]         = "Produit de",
  ["l'espérance de"]        = "Espérance de",
  ["l'écart type de"]       = "Écart type de",
  ["le quantile"]           = "Quantile",
  ["la probabilité que"]    = "Probabilité que",
}

local CALC_VERBS = {
  "Calcule", "Détermine", "Donne", "Cherche", "Trouve", "Établis", "Évalue",
}

local function normaliser_action_calcul(line)
  local ind, inner = line:match("^(%s*)<(.+)>%s*$")
  if not inner then return line end
  for _, v in ipairs(CALC_VERBS) do
    local reste = inner:match("^" .. v .. "%s+(.+)$")
    if reste then
      for fr, interne in pairs(CALC_NOUNS) do
        local arg = reste:match("^" .. fr:gsub("[%-']", "%%%0") .. "%s+(.+)$")
        if arg then
          return ind .. "<" .. interne .. " " .. arg .. ">"
        end
      end
      local ordinal = reste:match("^les%s+racines%s+([%a\128-\255]+)%s+de%s+")
      if ordinal then
        err("racines « " .. ordinal .. " » : ordinal non reconnu. Les "
          .. "racines n-ièmes se disent en toutes lettres, de « carrées » "
          .. "à « douzièmes » : <Calcule les racines cinquièmes de "
          .. "l'unité>, <Calcule les racines cubiques de 8i>.")
      end
    end
  end
  return line
end

-- Factorisation plurielle des actions de calcul (règle n°10) : le pluriel
-- se marque sur le nom — « <Calcule les dérivées de f, g et h> » vaut
-- trois calculs, un par fonction. L'expansion demande un nom au pluriel
-- ET une pure liste de noms (au moins deux), virgules entre eux, « et »
-- avant le dernier. Un nom au singulier suivi d'une liste est refusé ;
-- « les zéros de » et « les valeurs propres de », pluriels par nature,
-- se distribuent tels quels.
local CALC_NOUNS_PLURIELS = {
  ["les dérivées secondes de"]  = "la dérivée seconde de",
  ["les dérivées de"]           = "la dérivée de",
  ["les primitives de"]         = "la primitive de",
  ["les formes canoniques de"]  = "la forme canonique de",
  ["les déterminants de"]       = "le déterminant de",
  ["les inverses de"]           = "l'inverse de",
  ["les espérances de"]         = "l'espérance de",
  ["les écarts types de"]       = "l'écart type de",
  ["les zéros de"]              = "les zéros de",
  ["les valeurs propres de"]    = "les valeurs propres de",
}

local CALC_PLURIELS_ORDONNES = {}
for k in pairs(CALC_NOUNS_PLURIELS) do
  CALC_PLURIELS_ORDONNES[#CALC_PLURIELS_ORDONNES + 1] = k
end
table.sort(CALC_PLURIELS_ORDONNES, function(a, b) return #a > #b end)

local function liste_de_noms(arg)
  local noms = {}
  for nom in arg:gsub("%s+et%s+", ", "):gmatch("[^,]+") do
    nom = trim(nom)
    if not nom:match("^[%a][%w_]*$") then return nil end
    noms[#noms + 1] = nom
  end
  if #noms < 2 then return nil end
  return noms
end

-- Règle n°12 : une balise commence par un verbe d'action à l'impératif
-- présent (<Dresse un tableau ...>, <Affiche un cadre ...>, <Trace ...>),
-- jamais par l'objet nu — l'objet porte toujours son article, et
-- l'article suit un verbe. Seules exceptions : les styles (<gras rouge>),
-- les commandes structurelles (<section>, <table des matières>) et les
-- définitions d'alias (soit nom = <cadre ...>), qui décrivent un objet
-- sans le poser dans le document.
local OBJETS_VERBE = {
  ["cadre"] = true, ["tableau"] = true, ["grille"] = true,
  ["image"] = true, ["matrice"] = true,
  ["système"] = true, ["statistique"] = true, ["arbre"] = true,
  ["liste"] = true, ["rangée"] = true,
  -- Noms calculants : jamais nus non plus — <Calcule la dérivée de f>,
  -- pas <Dérivée de f>. Les formes internes sont produites après cette
  -- vérification et ne la traversent pas.
  ["Dérivée"] = true, ["Limite"] = true, ["Développement"] = true,
  ["Zéros"] = true, ["Primitive"] = true, ["Racines"] = true,
  ["Forme"] = true, ["Intégrale"] = true, ["Valeurs"] = true,
  ["Déterminant"] = true, ["Inverse"] = true, ["Somme"] = true,
  ["Produit"] = true, ["Espérance"] = true, ["Écart"] = true,
  ["Quantile"] = true, ["Probabilité"] = true,
}

local function verifier_verbe_objet(line)
  if line:match("^%s*soit%s+[%w\128-\255_%-]+%s*%b{}%s*=%s*<")
  or line:match("^%s*soit%s+[%w\128-\255_%-]+%s*=%s*<") then
    return
  end
  local l = line:gsub("<<", "  ")
  for tete in l:gmatch("<([%a\128-\255][%w\128-\255_%-]*)") do
    if OBJETS_VERBE[tete] then
      err("une balise commence par un verbe d'action à l'impératif "
        .. "présent — <Dresse un tableau ...>, <Affiche un cadre ...>, "
        .. "<Insère une image ...>, <Construis un arbre ...> — jamais "
        .. "par l'objet nu : « <" .. tete .. " ... ». L'objet porte "
        .. "toujours son article, et l'article suit un verbe "
        .. "(règle n°12).")
    end
  end
end

local function etendre_calcul_pluriel(body)
  local out = {}
  for line in (body .. "\n"):gmatch("(.-)\n") do
    local fait = false
    local ind, inner = line:match("^(%s*)<(.+)>%s*$")

    -- Objets pluriels après un verbe d'action : « <Dresse les tableaux
    -- de variations de f, g et h> » vaut trois tableaux, un par
    -- fonction — l'article pluriel (ou un nombre, vérifié) factorise.
    if inner then
      for _, v in ipairs(V.VERBES_OBJET) do
        local reste = inner:match("^" .. v .. "%s+(.+)$")
        if reste then
          local nb, tail = reste:match("^(%d+)%s+(.+)$")
          local corps = tail or reste:match("^[ld]es%s+(.+)$")
          if corps then
            for _, obj in ipairs { "variations", "signes" } do
              local arg = corps:match("^tableaux%s+de%s+" .. obj .. "%s+de%s+(.+)$")
              if arg then
                local noms = liste_de_noms(arg)
                if noms then
                  if nb and tonumber(nb) ~= #noms then
                    err("le compte n'y est pas : la phrase annonce " .. nb
                      .. " tableaux de " .. obj .. " mais nomme " .. #noms
                      .. " fonctions. Le nombre, comme l'article pluriel, "
                      .. "factorise : il doit correspondre au nombre de "
                      .. "fonctions nommées.")
                  end
                  for _, nom in ipairs(noms) do
                    out[#out + 1] = ind .. "<" .. v .. " le tableau de "
                                  .. obj .. " de " .. nom .. ">"
                  end
                  fait = true
                end
              end
              if fait then break end
            end
          end
        end
        if fait then break end
      end
    end

    if inner and not fait then
      for _, v in ipairs(CALC_VERBS) do
        local reste = inner:match("^" .. v .. "%s+(.+)$")
        if reste then
          for _, fr in ipairs(CALC_PLURIELS_ORDONNES) do
            local arg = reste:match("^" .. fr:gsub("[%-']", "%%%0") .. "%s+(.+)$")
            if arg then
              local noms = liste_de_noms(arg)
              if noms then
                local sing = CALC_NOUNS_PLURIELS[fr]
                for _, nom in ipairs(noms) do
                  out[#out + 1] = ind .. "<" .. v .. " " .. sing .. " " .. nom .. ">"
                end
                fait = true
              end
            end
            if fait then break end
          end
          if not fait then
            -- Nom au singulier suivi d'une liste : le pluriel manque.
            for fr in pairs(CALC_NOUNS) do
              if not CALC_NOUNS_PLURIELS[fr] then
                local arg = reste:match("^" .. fr:gsub("[%-']", "%%%0") .. "%s+(.+)$")
                if arg and liste_de_noms(arg) then
                  err("le pluriel factorise, et se marque sur le nom : "
                    .. "« <" .. v .. " " .. fr:gsub("^l[ae]s?%s+", "les ")
                    .. " f, g et h> » se dit avec le nom au pluriel — "
                    .. "par exemple « les dérivées de f, g et h » — "
                    .. "tandis que le singulier ne prend qu'une seule "
                    .. "fonction : « " .. fr .. " f ».")
                end
              end
            end
          end
        end
        if fait then break end
      end
    end
    if not fait then out[#out + 1] = line end
  end
  local res = table.concat(out, "\n")
  return (res:gsub("\n$", ""))
end

local function normaliser_verbe(line)
  for _, v in ipairs(V.VERBES_OBJET) do
    line = line:gsub("<" .. v .. "%s+([^<>]*)", function(rest)
      rest = rest:gsub("^[Uu]ne?%s+", ""):gsub("^[Ll]es?%s+", "")
                 :gsub("^[Ll]a%s+", ""):gsub("^[Ll]'%s*", "")
      return "<" .. rest
    end)
  end
  return line
end

local function remplacer_tetes(line)
  local out, i, n = {}, 1, #line
  while i <= n do
    local c = line:sub(i, i)
    if c == "<" and line:sub(i + 1, i + 1) == "<" then
      out[#out + 1] = "<<"
      i = i + 2
    elseif c == "<" then
      local j, depth, fin = i + 1, 0, nil
      while j <= n do
        local ch = line:sub(j, j)
        if ch == "{" then depth = depth + 1
        elseif ch == "}" then depth = depth - 1
        elseif ch == "<" and depth == 0 then break
        elseif ch == ">" and depth == 0 then fin = j; break
        end
        j = j + 1
      end
      if fin then
        out[#out + 1] = "<" .. translate_tag_head(line:sub(i + 1, fin - 1)) .. ">"
        i = fin + 1
      else
        out[#out + 1] = c
        i = i + 1
      end
    elseif c == ">" and line:sub(i + 1, i + 1) == ">" then
      out[#out + 1] = ">>"
      i = i + 2
    else
      out[#out + 1] = c
      i = i + 1
    end
  end
  return table.concat(out)
end

local function translate_line(line)
  local ctrl = translate_control(line)
  if ctrl then line = ctrl end
  return translate_math_zones(remplacer_tetes(line))
end

local function translate_table_row(line)
  local indent, row = line:match("^([ ]*\t?)(.*)$")
  if row == "" then return line end
  local function fusion(span)
    return function(n, attrs, contenu)
      local gras = attrs:find("%f[%a]entête%f[%A]") ~= nil
      attrs = attrs:gsub("%f[%a]entête%f[%A]", "")
      attrs = attrs:gsub("%f[%w]en%s+([tmb][lcr])%f[^%w]", "%1")
      if gras then
        contenu = "{<b>" .. contenu .. "}"
      end
      return "<" .. span .. ":" .. n .. " "
          .. translate_object_words(attrs) .. ">" .. contenu
    end
  end
  row = row:gsub("<dans%s+une%s+cellule%s+qui%s+occupera?%s+(%d+)%s+colonnes?%s*([^<>]-)>(%b{})",
                 fusion("colspan"))
  row = row:gsub("<dans%s+une%s+cellule%s+qui%s+occupera?%s+(%d+)%s+lignes?%s*([^<>]-)>(%b{})",
                 fusion("rowspan"))
  row = row:gsub("<(%d+)%s*colonnes?%s*([^<>]-)>(%b{})", fusion("colspan"))
  row = row:gsub("<(%d+)%s*lignes?%s*([^<>]-)>(%b{})",   fusion("rowspan"))
  local cells = {}
  for cell in (row .. "\t"):gmatch("(.-)\t") do
    cell = trim(cell)
    if cell == "" then cell = "." end
    cells[#cells + 1] = cell
  end
  while #cells > 1 and cells[#cells] == "." and row:match("%\t%s*$") == nil do
    table.remove(cells)
  end
  local joined = {}
  for i, c in ipairs(cells) do joined[i] = translate_line(c) end
  return indent .. table.concat(joined, " | ")
end

-- Un « < » hors maths ouvre-t-il une balise ? Une balise commence par une
-- lettre (<cadre, <Trace, <titre) ou par un nombre immédiatement suivi
-- d'un mot (<3lignes, <4 colonnes). Un « < » suivi d'une espace ou d'un
-- nombre isolé est un comparateur (tant que n < 3, si x <3) : littéral.
local function chevron_est_balise(l, p)
  local rest = l:sub(p + 1)
  if rest:match("^[%a\128-\255]") then return true end
  if rest:match("^%d+%s?[%a\128-\255]") then return true end
  return false
end

local function tete_ouverte(line)
  local l = line:gsub("<<", ""):gsub(">>", "")
  local ouverte, depth, in_math = false, 0, false
  for i = 1, #l do
    local c = l:sub(i, i)
    if c == "$" then
      in_math = not in_math
    elseif in_math then
      -- ignore < > { } rencontrés dans une zone de maths : ce sont des
      -- opérateurs de comparaison (<=, =>, <=>...), pas des balises.
    elseif c == "{" then depth = depth + 1
    elseif c == "}" then depth = depth - 1
    elseif c == "<" and depth <= 0 then
      if chevron_est_balise(l, i) then ouverte = true end
    elseif c == ">" and depth <= 0 then ouverte = false
    end
  end
  return ouverte
end

local function joindre_notes(body)
  local lines, buf, buf_kind = {}, nil, nil
  for line in (body .. "\n"):gmatch("(.-)\n") do
    if buf then
      buf = buf .. " " .. line:gsub("^%s+", "")
      local fini
      if buf_kind == "note" then
        fini = select(2, buf:gsub("{", "")) <= select(2, buf:gsub("}", ""))
      else
        fini = not tete_ouverte(buf)
      end
      if fini then
        lines[#lines + 1] = buf; buf, buf_kind = nil, nil
      end
    else
      local notepos = line:find("<note>%s*{") or line:find("<footnote>%s*{")
      if notepos then
        local no = select(2, line:gsub("{", ""))
        local nc = select(2, line:gsub("}", ""))
        if no > nc then buf, buf_kind = line, "note"
        else lines[#lines + 1] = line end
      elseif tete_ouverte(line) then
        buf, buf_kind = line, "tete"
      else
        lines[#lines + 1] = line
      end
    end
  end
  if buf then lines[#lines + 1] = buf end
  return table.concat(lines, "\n")
end

local function fn_requise(nom, quoi)
  local def = L._fns and L._fns[nom]
  if not def then
    error("texecole : <" .. quoi .. " de " .. nom .. "> demande que la "
      .. "fonction ait été posée par <Soit>une fonction " .. nom
      .. "(x) = ... auparavant.", 0)
  end
  return def, def:match("%((%a[%w_]*)%)") or "x", def:match("=%s*(.+)$")
end

local function mat_requise(nom, quoi)
  local mat = L._mats and L._mats[nom]
  if not mat then
    error("texecole : <" .. quoi .. " de " .. nom .. "> demande que la "
      .. "matrice ait été posée : <Soit la matrice " .. nom .. ">{ ... }", 0)
  end
  return mat
end

local LOI_OPS = {
  ["<="] = "le", [">="] = "ge", ["="] = "eq", ["<"] = "lt", [">"] = "gt",
}

local function loi_ou_erreur(nomloi)
  local code = V.LOIS[nomloi]
  if not code then
    error("texecole : loi inconnue : « " .. nomloi .. " » (attendu : "
      .. "normale, binomiale, poisson, uniforme, exponentielle, student, "
      .. "khi-deux).", 0)
  end
  return code
end

local function params_loi(p)
  return trim(num_fr(p):gsub("%s*;%s*", " "))
end

local function transformer_edo(eq, f, var)
  var = var or "x"
  eq = num_fr(eq)
  eq = eq:gsub(f .. "''", "\1"):gsub(f .. "'", "\2")
  eq = eq:gsub("%f[%a]" .. f .. "%f[%A]", "\3")
  eq = eq:gsub("\1", "Derivative(" .. f .. "(" .. var .. ")," .. var .. ",2)")
  eq = eq:gsub("\2", "Derivative(" .. f .. "(" .. var .. ")," .. var .. ")")
  eq = eq:gsub("\3", f .. "(" .. var .. ")")
  return eq
end

local function transformer_edp(eq, f)
  eq = num_fr(eq)
  eq = eq:gsub(f .. "_xx", "\1"):gsub(f .. "_yy", "\2")
  eq = eq:gsub(f .. "_xy", "\4"):gsub(f .. "_x", "\5"):gsub(f .. "_y", "\6")
  eq = eq:gsub("%f[%a]" .. f .. "%f[%A]", "\3")
  eq = eq:gsub("\1", "Derivative(" .. f .. "(x,y),x,2)")
  eq = eq:gsub("\2", "Derivative(" .. f .. "(x,y),y,2)")
  eq = eq:gsub("\4", "Derivative(" .. f .. "(x,y),x,y)")
  eq = eq:gsub("\5", "Derivative(" .. f .. "(x,y),x)")
  eq = eq:gsub("\6", "Derivative(" .. f .. "(x,y),y)")
  eq = eq:gsub("\3", f .. "(x,y)")
  return eq
end

local function traduire_zone(inner)
  local nom, reste = inner:match("^%s*([%a_][%w_]*)%s*:%s*(.*)$")
  if not nom then
    nom, reste = inner:match("^%s*([%a_][%w_]*)%s*(.*)$")
  end
  if not nom then
    error("texecole : zone de grille illisible : « [" .. inner .. "] » — "
      .. "la forme est [nom en mc avec attributs] : le placement "
      .. "s'introduit par « en », les attributs par « avec ».", 0)
  end
  local VNAT = { haut = "t", milieu = "m", bas = "b" }
  local HNAT = { gauche = "l", centre = "c", droite = "r" }
  local vert, horiz, code
  local attrtxt
  for piece in (reste .. ","):gmatch("(.-),") do
    local p = trim(piece)
    if attrtxt then
      if p ~= "" then attrtxt = attrtxt .. ", " .. p end
    elseif p ~= "" then
      -- placement « en CODE » éventuel en tête de morceau
      local c2, suite = p:match("^en%s+([tmb][lcrj])%f[%W]%s*(.*)$")
      if not c2 then
        c2, suite = p:match("^en%s+(j)%f[%W]%s*(.*)$")
      end
      if c2 then code = c2; p = trim(suite) end
      if p:match("^avec%f[%W]") then
        attrtxt = p
      elseif p ~= "" then
        local nu = p:gsub("^en%s+", ""):gsub("^au%s+", ""):gsub("^à%s+", "")
        if VNAT[nu] then vert = VNAT[nu]
        elseif HNAT[nu] then horiz = HNAT[nu]
        elseif p:match("^[tmb][lcr]%f[%W]") or p:match("^j%f[%W]") then
          err("le placement d'une zone s'introduit par « en » : "
            .. "[" .. nom .. " en " .. p:match("^%S+") .. " ...] — "
            .. "comme dans « en mc » (milieu, centré) ou « en j » "
            .. "(justifié).")
        else
          err("propriété de zone illisible : « " .. p .. " ». Une zone "
            .. "s'écrit [nom en mc avec une bordure bleue, un fond "
            .. "blanc et des coins arrondis de 2 mm] — le placement "
            .. "d'abord (« en mc », ou « en haut, à gauche »), puis "
            .. "les attributs introduits par « avec », séparés par "
            .. "des virgules, « et » avant le dernier.")
        end
      end
    end
  end
  if vert or horiz then code = (vert or "m") .. (horiz or "c") end
  local morceaux = { nom }
  if code then morceaux[#morceaux + 1] = code end
  if attrtxt then
    local mots = translate_object_words(attrtxt, true)
    if mots ~= "" then morceaux[#morceaux + 1] = mots end
  end
  return table.concat(morceaux, " ")
end

local function delta_braces(line)
  local o = select(2, line:gsub("{", ""))
  local c = select(2, line:gsub("}", ""))
  return o - c
end

-- Deux écritures naturelles de la matrice, parenthèses ou crochets
-- servant eux-mêmes de délimiteur de bloc — au lieu des accolades
-- neutres habituelles — puis ramenées à la forme canonique interne
-- <... la matrice>{...} / <... la matrice entre crochets>{...} qui,
-- seule, est ensuite traduite. Deux tournures acceptées :
--   <Affiche la matrice(          <Affiche>la matrice(
--       1 ; 2 ; 3                     1 ; 2 ; 3
--   )>                             )
local function convertir_matrices_delimiteurs(body)
  body = body:gsub("<([^<>]-)matrice%s*%((.-)\n%s*%)>", function(prefixe, rangs)
    return "<" .. prefixe .. "matrice>{" .. rangs .. "\n}"
  end)
  body = body:gsub("<([^<>]-)matrice%s*%[(.-)\n%s*%]>", function(prefixe, rangs)
    return "<" .. prefixe .. "matrice entre crochets>{" .. rangs .. "\n}"
  end)
  body = body:gsub("<([%a][%w]*)>%s*la%s+matrice%s*%((.-)\n%s*%)", function(verbe, rangs)
    return "<" .. verbe .. " la matrice>{" .. rangs .. "\n}"
  end)
  body = body:gsub("<([%a][%w]*)>%s*la%s+matrice%s*%[(.-)\n%s*%]", function(verbe, rangs)
    return "<" .. verbe .. " la matrice entre crochets>{" .. rangs .. "\n}"
  end)
  -- Forme scindée nommée (référence ultérieure) : <VERBE>la matrice NOM {
  -- ou <VERBE>le système NOM { — le nom sert à un calcul plus loin
  -- (déterminant, inverse, valeurs propres...), donc les accolades
  -- neutres restent le délimiteur, comme pour la forme combinée.
  body = body:gsub(
    "<([%a][%w]*)>%s*la%s+matrice%s+([%a_][%w_]*)%s+entre%s+crochets%s*{",
    function(verbe, nom)
      return "<" .. verbe .. " la matrice " .. nom .. " entre crochets>{"
    end)
  body = body:gsub("<([%a][%w]*)>%s*la%s+matrice%s+([%a_][%w_]*)%s*{",
    function(verbe, nom) return "<" .. verbe .. " la matrice " .. nom .. ">{" end)
  body = body:gsub("<([%a][%w]*)>%s*le%s+système%s+([%a_][%w_]*)%s*{",
    function(verbe, nom) return "<" .. verbe .. " le système " .. nom .. ">{" end)
  return body
end

function L.traduire(body)
  -- <Représente graphiquement ...> est le verbe de l'analyse (fonction,
  -- repère, courbe, droite graduée) : son vocabulaire de figures est
  -- celui, déjà fermé, de <Trace>. Pour tout le reste (une statistique,
  -- par exemple), qui ne relève pas de ce vocabulaire de figures mais
  -- du registre générique verbe+objet, il se comporte comme <Affiche>.
  body = body:gsub("<[Rr]eprésente%s+graphiquement>%s*{", "<Trace>{")
  body = body:gsub("<[Rr]eprésente%s+graphiquement%s+dans%s+un%s+repère",
    "<Trace dans un repère")
  body = body:gsub("<[Rr]eprésente%s+graphiquement%s+un%s+repère", "<Trace un repère")
  body = body:gsub("<[Rr]eprésente%s+graphiquement%s+la%s+fonction", "<Trace la fonction")
  body = body:gsub("<[Rr]eprésente%s+graphiquement%s+les%s+fonctions", "<Trace les fonctions")
  body = body:gsub("<[Rr]eprésente%s+graphiquement%s+des%s+fonctions", "<Trace des fonctions")
  body = body:gsub("<[Rr]eprésente%s+graphiquement%s+(%d+%s+fonctions)", "<Trace %1")
  body = body:gsub("<[Rr]eprésente%s+graphiquement%s+la%s+droite%s+graduée",
    "<Trace la droite graduée")
  body = body:gsub("<[Rr]eprésente%s+graphiquement%s+", "<Affiche ")
  body = convertir_matrices_delimiteurs(body)
  body = joindre_notes(body)
  body = etendre_calcul_pluriel(body)
  local out = {}
  local mode, acc, frame = "normal", nil, nil

  for line in (body .. "\n"):gmatch("(.-)\n") do
    verifier_verbe_objet(line)
    if mode == "normal" then
      line = normaliser_action_calcul(line)
      line = normaliser_verbe(line)
      if line:match("^%s*<Soit>%s*{%s*$") then
        mode, acc = "soit", {}
      elseif line:match("^%s*<Soit>") then
        local decl = trim(line:match("^%s*<Soit>%s*(.*)$"))
        local t = P.translate_soit_decl(decl)
        out[#out + 1] = "Soit " .. t.display
        inscrire_fns(out, t)
        if t.points then
          L._points = L._points or {}
          for _, pt in ipairs(t.points) do
            L._points[pt.nom] = pt.co
          end
        end
      elseif line:match("^%s*<On%s+[%aè]+>") then
        local verbe, reste = line:match("^%s*<On%s+([%aè]+)>%s*(.*)$")
        if verbe ~= "pose" and verbe ~= "considère" and verbe ~= "note" then
          error("texecole : « <On " .. verbe .. "> » n'est pas une action : "
            .. "les tournures reconnues sont <On pose>, <On considère> et "
            .. "<On note>, synonymes de <Soit>.", 0)
        end
        local t = P.translate_soit_decl(trim(reste))
        out[#out + 1] = "On " .. verbe .. " " .. t.display
        inscrire_fns(out, t)
        if t.points then
          L._points = L._points or {}
          for _, pt in ipairs(t.points) do L._points[pt.nom] = pt.co end
        end
      elseif line:match("^%s*<Trace%f[%s>].->%s*{%s*$")
          or line:match("^%s*<Trace>%s*{%s*$") then
        local head = line:match("^%s*<Trace%s*(.-)>%s*{%s*$") or ""
        mode, acc = "trace", {}
        frame = nil
        if trim(head) ~= "" then
          local t = P.translate_trace_phrase(head)
          if t.kind == "frame" then
            frame = t
          else
            acc[#acc + 1] = t.line
          end
        end
      elseif line:match("^%s*<Trace%f[%s>]") then
        local phrase = line:match("^%s*<Trace%s*(.-)>%s*(.*)$")
        local after  = line:match("^%s*<Trace%s*.->%s*(.*)$") or ""
        phrase = trim((phrase or "") .. " " .. after)
        local t = P.translate_trace_phrase(phrase)
        if t.kind == "plot" then
          out[#out + 1] = "<plot " .. t.line .. ">"
        elseif t.kind == "numberline" then
          out[#out + 1] = "<numberline " .. t.line .. ">"
        elseif t.kind == "frame" then
          local ax = "axes:{" .. (t.x or "{-5, 5}"):gsub("[{}]", "") .. ", "
                             .. (t.y or "{-5, 5}"):gsub("[{}]", "") .. "}"
          local extra = {}
          for _, w in ipairs(t.opts or {}) do extra[#extra+1] = w end
          out[#out + 1] = "<draw " .. ax
            .. (#extra > 0 and (" " .. table.concat(extra, " ")) or "")
            .. ">{"
          out[#out + 1] = "}"
        else
          out[#out + 1] = "<draw>" .. t.line
        end
      elseif line:match("^%s*<Soit%s+le%s+système%s+[%a_][%w_]*%s*>%s*{%s*$") then
        acc = { nom = line:match("<Soit%s+le%s+système%s+([%a_][%w_]*)"),
                eqs = {} }
        out[#out + 1] = "<system>{"
        mode = "systeme"

      elseif line:match("^%s*<Intégrale%s+numérique%s+de%s+[%a_][%w_]*%s+entre%s+") then
        local nom, a, b = line:match(
          "^%s*<Intégrale%s+numérique%s+de%s+([%a_][%w_]*)%s+entre%s+(%S+)%s+et%s+(%S+)%s*>%s*$")
        if not nom then
          error("texecole : la forme est <Intégrale numérique de f entre a et b>.", 0)
        end
        local _, fvar, fexpr = fn_requise(nom, "Intégrale numérique")
        out[#out + 1] = "<sympynint " .. nom .. " " .. fvar .. " "
          .. a:gsub(",", ".") .. " " .. b:gsub(",", ".") .. ">{"
        out[#out + 1] = fexpr
        out[#out + 1] = "}"

      elseif line:match("^%s*<Limite%s+de%s+[%a_][%w_]*%s+en%s+") then
        local nom, reste = line:match("^%s*<Limite%s+de%s+([%a_][%w_]*)%s+en%s+(.-)%s*>%s*$")
        if not nom then
          error("texecole : la forme est <Limite de f en a>, avec a fini, +infini ou -infini, suivie au besoin de « à droite » ou « à gauche ».", 0)
        end
        local pt, dir = reste:match("^(%S+)%s+à%s+(droite)$")
        if not pt then pt, dir = reste:match("^(%S+)%s+à%s+(gauche)$") end
        if not pt then pt, dir = reste, "" end
        local _, fvar, fexpr = fn_requise(nom, "Limite")
        out[#out + 1] = "<sympylimit " .. nom .. " " .. fvar .. " "
          .. pt:gsub(",", ".") .. (dir ~= "" and (" " .. dir) or "") .. ">{"
        out[#out + 1] = fexpr
        out[#out + 1] = "}"

      elseif line:match("^%s*<Développement%s+limité%s+de%s+") then
        local nom, a, n = line:match(
          "^%s*<Développement%s+limité%s+de%s+([%a_][%w_]*)%s+en%s+(%S+)%s+à%s+l'ordre%s+(%d+)%s*>%s*$")
        if not nom then
          error("texecole : la forme est <Calcule le développement limité de f en a à l'ordre n>.", 0)
        end
        local _, fvar, fexpr = fn_requise(nom, "Développement limité")
        out[#out + 1] = "<sympydl " .. nom .. " " .. fvar .. " "
          .. a:gsub(",", ".") .. " " .. n .. ">{"
        out[#out + 1] = fexpr
        out[#out + 1] = "}"

      elseif line:match("^%s*<Racines%s+") then
        local ORD = { ["carrées"] = 2, ["cubiques"] = 3, ["quatrièmes"] = 4,
          ["cinquièmes"] = 5, ["sixièmes"] = 6, ["septièmes"] = 7,
          ["huitièmes"] = 8, ["neuvièmes"] = 9, ["dixièmes"] = 10,
          ["onzièmes"] = 11, ["douzièmes"] = 12 }
        local ordinal, expr = line:match(
          "^%s*<Racines%s+([%a\128-\255]+)%s+de%s+(.-)%s*>%s*$")
        local n = ordinal and ORD[ordinal]
        if not n then
          error("texecole : la forme est <Calcule les racines n-ièmes de "
            .. "EXPR> avec l'ordinal en toutes lettres — racines carrées, "
            .. "cubiques, quatrièmes... douzièmes. Par exemple : "
            .. "<Calcule les racines cinquièmes de 1> (les racines "
            .. "cinquièmes de l'unité).", 0)
        end
        expr = expr:gsub("^l'unité$", "1")
        out[#out + 1] = "<sympynroots " .. n .. ">{"
        out[#out + 1] = num_fr(expr)
        out[#out + 1] = "}"

      elseif line:match("^%s*<Factorise%s+.->%s*$")
          or line:match("^%s*<Simplifie%s+.->%s*$") then
        local verbe, expr = line:match("^%s*<(%a+)%s+(.-)>%s*$")
        out[#out + 1] = "<" .. (verbe == "Factorise" and "sympyfactor" or "sympysimplify") .. ">{"
        out[#out + 1] = num_fr(expr)
        out[#out + 1] = "}"

      elseif line:match("^%s*<Décompose%s+.->%s*$") then
        local expr = line:match("^%s*<Décompose%s+(.-)>%s*$")
        expr = expr:gsub("^en%s+éléments%s+simples%s+", "")
        out[#out + 1] = "<sympyapart>{"
        out[#out + 1] = num_fr(expr)
        out[#out + 1] = "}"

      elseif line:match("^%s*<Résous%s+numériquement%s+l'équation%s+.->%s*$") then
        local eq, a, b = line:match(
          "^%s*<Résous%s+numériquement%s+l'équation%s+(.-)%s+sur%s*%[%s*(.-)%s*;%s*(.-)%s*%]%s*>%s*$")
        if not eq then
          error("texecole : la forme est <Résous numériquement l'équation "
            .. "EXPR = EXPR sur [a ; b]> — l'intervalle de recherche est "
            .. "obligatoire.", 0)
        end
        out[#out + 1] = "<scipyroot " .. num_fr(trim(a)) .. " "
          .. num_fr(trim(b)) .. ">{"
        out[#out + 1] = num_fr(eq)
        out[#out + 1] = "}"

      elseif line:match("^%s*<Résous%s+l'équation%s+aux%s+dérivées%s+partielles%s+.->%s*$") then
        local eq = line:match(
          "^%s*<Résous%s+l'équation%s+aux%s+dérivées%s+partielles%s+(.-)>%s*$")
        local f = eq:match("([%a])_[xy]") or "u"
        out[#out + 1] = "<sympypde " .. f .. ">{"
        out[#out + 1] = transformer_edp(eq, f)
        out[#out + 1] = "}"

      elseif line:match("^%s*<Résous%s+l'équation%s+différentielle%s+.->%s*$") then
        local eq = line:match("^%s*<Résous%s+l'équation%s+différentielle%s+(.-)>%s*$")
        local fdecl, vdecl = eq:match(",%s*d'inconnue%s+([%a])%(([%a])%)%s*$")
        if fdecl then
          eq = eq:gsub(",%s*d'inconnue%s+[%a]%([%a]%)%s*$", "")
        end
        local f = fdecl or eq:match("([%a])''") or eq:match("([%a])'") or "y"
        local var = vdecl or "x"
        out[#out + 1] = "<sympyode " .. f .. " " .. var .. ">{"
        out[#out + 1] = transformer_edo(eq, f, var)
        out[#out + 1] = "}"

      elseif line:match("^%s*<Résous%s+dans%s+.-%s+l'équation%s+.->%s*$") then
        local dom, eq = line:match(
          "^%s*<Résous%s+dans%s+(.-)%s+l'équation%s+(.-)>%s*$")
        local domaine = V.DOMAINES[dom]
        if not domaine then
          error("texecole : domaine de résolution inconnu : « " .. dom
            .. " » (attendu : RR, CC, ZZ, NN, QQ ou leur nom en toutes "
            .. "lettres : les réels, les complexes...).", 0)
        end
        out[#out + 1] = "<sympysolveeq " .. domaine .. ">{"
        out[#out + 1] = num_fr(eq)
        out[#out + 1] = "}"

      elseif line:match("^%s*<Résous%s+l'équation%s+.->%s*$") then
        local eq = line:match("^%s*<Résous%s+l'équation%s+(.-)>%s*$")
        out[#out + 1] = "<sympysolveeq>{"
        out[#out + 1] = num_fr(eq)
        out[#out + 1] = "}"

      elseif line:match("^%s*<Soit%s+la%s+matrice%s+[%a_][%w_]*%s*>%s*{%s*$")
          or line:match("^%s*<Soit%s+la%s+matrice%s+[%a_][%w_]*%s+entre%s+crochets%s*>%s*{%s*$") then
        local crochets = line:match("entre%s+crochets%s*>%s*{%s*$") ~= nil
        acc = { nom = line:match("<Soit%s+la%s+matrice%s+([%a_][%w_]*)"), rows = {} }
        out[#out + 1] = crochets and "<bmatrix>{" or "<matrix>{"
        mode = "matrice"

      elseif line:match("^%s*<Déterminant%s+de%s+[%a_][%w_]*%s*>%s*$")
          or line:match("^%s*<Inverse%s+de%s+[%a_][%w_]*%s*>%s*$") then
        local verbe, nom = line:match("^%s*<([%a\128-\255]+)%s+de%s+([%a_][%w_]*)%s*>%s*$")
        local mat = mat_requise(nom, verbe)
        out[#out + 1] = "<" .. (verbe == "Déterminant" and "sympydet" or "sympyinv")
          .. " " .. nom .. ">{"
        for _, r in ipairs(mat) do out[#out + 1] = r end
        out[#out + 1] = "}"

      elseif line:match("^%s*<Valeurs%s+propres%s+de%s+[%a_][%w_]*%s*>%s*$") then
        local nom = line:match("de%s+([%a_][%w_]*)%s*>")
        local mat = mat_requise(nom, "Valeurs propres")
        out[#out + 1] = "<sympyeigen " .. nom .. ">{"
        for _, r in ipairs(mat) do out[#out + 1] = r end
        out[#out + 1] = "}"

      elseif line:match("^%s*<Diagonalise%s+[%a_][%w_]*%s*>%s*$") then
        local nom = line:match("^%s*<Diagonalise%s+([%a_][%w_]*)%s*>%s*$")
        local mat = mat_requise(nom, "Diagonalise")
        out[#out + 1] = "<sympydiag " .. nom .. ">{"
        for _, r in ipairs(mat) do out[#out + 1] = r end
        out[#out + 1] = "}"

      elseif line:match("^%s*<Primitive%s+de%s+[%a_][%w_]*%s*>%s*$") then
        local nom = line:match("de%s+([%a_][%w_]*)%s*>")
        local _, fvar, fexpr = fn_requise(nom, "Primitive")
        out[#out + 1] = "<sympyprim " .. nom .. " " .. fvar .. ">{"
        out[#out + 1] = fexpr
        out[#out + 1] = "}"

      elseif line:match("^%s*<Développe%s+.->%s*$") then
        local expr = line:match("^%s*<Développe%s+(.-)>%s*$")
        expr = expr:gsub("^et%s+réduis%s+", "")
        out[#out + 1] = "<sympyexpand>{"
        out[#out + 1] = num_fr(expr)
        out[#out + 1] = "}"

      elseif line:match("^%s*<Forme%s+canonique%s+de%s+.->%s*$")
          or line:match("^%s*<Mets%s+.-%s+sous%s+forme%s+canonique%s*>%s*$") then
        local expr = line:match("^%s*<Forme%s+canonique%s+de%s+(.-)>%s*$")
                  or line:match("^%s*<Mets%s+(.-)%s+sous%s+forme%s+canonique%s*>%s*$")
        out[#out + 1] = "<sympycanon>{"
        out[#out + 1] = num_fr(expr)
        out[#out + 1] = "}"

      elseif line:match("^%s*<Somme%s+de%s+.-%s+pour%s+")
          or line:match("^%s*<Produit%s+de%s+.-%s+pour%s+") then
        local quoi, expr, var, a, b = line:match(
          "^%s*<(%a+)%s+de%s+(.-)%s+pour%s+([%a_][%w_]*)%s+de%s+(%S+)%s+à%s+(%S+)%s*>%s*$")
        if not quoi then
          error("texecole : la forme est <Calcule la somme de EXPR pour k de a à b> "
            .. "(ou <Produit de ...>).", 0)
        end
        out[#out + 1] = "<" .. (quoi == "Somme" and "sympysum" or "sympyprod")
          .. " " .. var .. " " .. num_fr(a) .. " " .. num_fr(b) .. ">{"
        out[#out + 1] = num_fr(expr)
        out[#out + 1] = "}"

      elseif line:match("^%s*<Probabilité%s+que%s+X%s*") then
        local op, val, nomloi, params = line:match(
          "^%s*<Probabilité%s+que%s+X%s*([<>]=?)%s*([%-%d,%.]+)%s+pour%s+la%s+loi%s+([%a%- ]+)%s*%((.-)%)%s*>%s*$")
        if not op then
          op = "="
          val, nomloi, params = line:match(
            "^%s*<Probabilité%s+que%s+X%s*=%s*([%-%d,%.]+)%s+pour%s+la%s+loi%s+([%a%- ]+)%s*%((.-)%)%s*>%s*$")
        end
        if not val then
          error("texecole : la forme est <Probabilité que X <= a pour la "
            .. "loi normale(m ; s)> — opérateurs <=, >=, =, <, > ; lois : "
            .. "normale(m;s), binomiale(n;p), poisson(l), uniforme(a;b), "
            .. "exponentielle(l), student(k), khi-deux(k).", 0)
        end
        local code = loi_ou_erreur(trim(nomloi))
        out[#out + 1] = "<scipyprob " .. LOI_OPS[op] .. " " .. num_fr(val)
          .. " " .. code .. " " .. params_loi(params) .. ">{"
        out[#out + 1] = "}"

      elseif line:match("^%s*<Quantile%s+d'ordre%s+") then
        local p, nomloi, params = line:match(
          "^%s*<Quantile%s+d'ordre%s+([%d,%.]+)%s+de%s+la%s+loi%s+([%a%- ]+)%s*%((.-)%)%s*>%s*$")
        if not p then
          error("texecole : la forme est <Calcule le quantile d'ordre 0,975 de la loi "
            .. "normale(0 ; 1)>.", 0)
        end
        local code = loi_ou_erreur(trim(nomloi))
        out[#out + 1] = "<scipyquantile " .. num_fr(p) .. " " .. code .. " "
          .. params_loi(params) .. ">{"
        out[#out + 1] = "}"

      elseif line:match("^%s*<Espérance%s+de%s+la%s+loi%s+")
          or line:match("^%s*<Écart%s+type%s+de%s+la%s+loi%s+") then
        local quoi = line:find("Espérance") and "mean" or "std"
        local nomloi, params = line:match(
          "de%s+la%s+loi%s+([%a%- ]+)%s*%((.-)%)%s*>%s*$")
        if not nomloi then
          error("texecole : la forme est <Espérance de la loi binomiale"
            .. "(10 ; 0,5)> ou <Écart type de la loi ...>.", 0)
        end
        local code = loi_ou_erreur(trim(nomloi))
        out[#out + 1] = "<scipymoment " .. quoi .. " " .. code .. " "
          .. params_loi(params) .. ">{"
        out[#out + 1] = "}"

      elseif line:match("^%s*<Ajuste%s+") then
        local fname, fvar, model, data = line:match(
          "^%s*<Ajuste%s+([%a_][%w_]*)%(([%a_][%w_]*)%)%s*=%s*(.-)%s+aux%s+données%s*(%b{})%s*>%s*$")
        if not fname then
          error("texecole : la forme est <Ajuste f(x) = a*exp(b*x) aux "
            .. "données {(0;1) (1;2,7) ...}> — les paramètres libres sont "
            .. "a, b, c, d.", 0)
        end
        out[#out + 1] = "<scipyfit " .. fname .. " " .. fvar .. ">{"
        out[#out + 1] = num_fr(model)
        out[#out + 1] = num_fr(data):gsub("%s*;%s*", ",")
        out[#out + 1] = "}"

      elseif line:match("^%s*<Étudie%s+les%s+variations%s+de%s+[%a_][%w_]*%s*>%s*$") then
        local nom = line:match("de%s+([%a_][%w_]*)%s*>")
        out[#out + 1] = "<vartab " .. nom .. ">"

      elseif line:match("^%s*<Dérivée%f[%s].-de%s+[%a_][%w_]*%s*>%s*$")
          or line:match("^%s*<Zéros%s+de%s+[%a_][%w_]*%s*>%s*$") then
        local ordre = 1
        if line:find("seconde") then ordre = 2
        elseif line:find("troisième") then ordre = 3 end
        local nom = line:match("de%s+([%a_][%w_]*)%s*>")
        local quoi = line:find("Zéros") and "Zéros" or "Dérivée"
        local _, fvar, fexpr = fn_requise(nom, quoi)
        if line:find("Zéros") then
          out[#out + 1] = "<sympyzeros " .. nom .. " " .. fvar .. ">{"
        else
          out[#out + 1] = "<sympyderiv " .. nom .. " " .. ordre .. " "
            .. fvar .. ">{"
        end
        out[#out + 1] = fexpr
        out[#out + 1] = "}"

      elseif line:match("^%s*<Résous%s+[%a_][%w_]*%s*,?%s*.->%s*$") then
        local nom = line:match("<Résous%s+([%a_][%w_]*)")
        local sysdef = L._sys and L._sys[nom]
        if not sysdef then
          error("texecole : <Résous " .. nom .. "> demande que le "
            .. "système ait été posé auparavant : <Soit le système "
            .. nom .. ">{ ... }", 0)
        end
        local pas = line:find("pas%s+à%s+pas") and " steps:on" or ""
        out[#out + 1] = "<solve" .. pas .. ">{"
        for _, eq in ipairs(sysdef) do out[#out + 1] = eq end
        out[#out + 1] = "}"

      elseif line:match("^%s*<arbre%f[%s>].->%s*{%s*$") then
        local head = line:match("^%s*<(.-)>%s*{%s*$")
        out[#out + 1] = "<" .. translate_tag_head(head) .. ">{"
        mode = "arbre"
        acc = { depth = 1 }

      elseif line:match("^%s*soit%s+[%a_][%w_]*%s*=%s*{%s*$") then
        out[#out + 1] = translate_control(line)
        mode = "soittable"
        acc = { depth = 1 }

      elseif line:match("^%s*<grille%f[%s>].->%s*{%s*$") then
        local head = line:match("^%s*<(.-)>%s*{%s*$")
        out[#out + 1] = "<" .. translate_tag_head(head) .. ">{"
        mode = "grille"
        acc = { depth = 1 }

      elseif line:match("^%s*<tableau%f[%s>].->%s*{%s*$") then
        local head = line:match("^%s*<(.-)>%s*{%s*$")
        out[#out + 1] = "<" .. translate_tag_head(head) .. ">{"
        mode = "tableau"
        acc = { depth = 1 }
      else
        out[#out + 1] = translate_line(line)
      end

    elseif mode == "matrice" then
      if line:match("^%s*}%s*$") then
        out[#out + 1] = line
        L._mats = L._mats or {}
        L._mats[acc.nom] = acc.rows
        acc = nil
        mode = "normal"
      else
        local r = num_fr(line)
        acc.rows[#acc.rows + 1] = trim(r)
        out[#out + 1] = r
      end

    elseif mode == "systeme" then
      if line:match("^%s*}%s*$") then
        out[#out + 1] = line
        L._sys = L._sys or {}
        L._sys[acc.nom] = acc.eqs
        acc = nil
        mode = "normal"
      else
        local eq = num_fr(line)
        acc.eqs[#acc.eqs + 1] = eq
        out[#out + 1] = eq
      end

    elseif mode == "soittable" then
      local d = delta_braces(line)
      if acc.depth + d <= 0 and line:match("^%s*}%s*$") then
        out[#out + 1] = line
        mode, acc = "normal", nil
      else
        acc.depth = acc.depth + d
        local ind, cle, val = line:match(
          "^(%s*)([%wÀ-ÿ_]+)%s*[:=]%s*(-?[%d,%.]+)%s*,?%s*$")
        if cle then
          out[#out + 1] = ind .. "[\"" .. cle .. "\"] = "
            .. num_fr(val) .. ","
        else
          out[#out + 1] = num_fr(line)
        end
      end

    elseif mode == "grille" then
      local d = delta_braces(line)
      if acc.depth + d <= 0 and line:match("^%s*}%s*$") then
        out[#out + 1] = line
        mode, acc = "normal", nil
      else
        acc.depth = acc.depth + d
        local zind, zin, zrest = line:match("^(%s*)%[(.-)%](%s*{.*)$")
        if zin then
          out[#out + 1] = zind .. "<area " .. traduire_zone(zin) .. ">"
            .. translate_line(zrest)
        else
          out[#out + 1] = translate_line(line)
        end
      end

    elseif mode == "arbre" then
      local d = delta_braces(line)
      if acc.depth + d <= 0 and line:match("^%s*}%s*$") then
        out[#out + 1] = line
        mode, acc = "normal", nil
      else
        acc.depth = acc.depth + d
        out[#out + 1] = num_fr(line)
      end

    elseif mode == "tableau" then
      local d = delta_braces(line)
      if acc.depth + d <= 0 and line:match("^%s*}%s*$") then
        out[#out + 1] = line
        mode, acc = "normal", nil
      else
        acc.depth = acc.depth + d
        local ctrl = translate_control(line)
        if ctrl then
          out[#out + 1] = ctrl
          if line:match("{%s*$") then acc.ctrl = (acc.ctrl or 0) + 1 end
        elseif line:match("^%s*}%s*$") then
          out[#out + 1] = line
          if (acc.ctrl or 0) > 0 then acc.ctrl = acc.ctrl - 1 end
        else
          local depouille = line
          for _ = 1, (acc.ctrl or 0) do
            depouille = depouille:gsub("^\t", "", 1)
          end
          out[#out + 1] = translate_table_row(depouille)
        end
      end

    elseif mode == "soit" then
      if line:match("^%s*}%s*$") then
        mode = "normal"
      else
        local decl = trim(line)
        if decl ~= "" then
          local t = P.translate_soit_decl(decl)
          out[#out + 1] = "<tab>" .. t.display
          inscrire_fns(out, t)
          if t.points then
            L._points = L._points or {}
            for _, pt in ipairs(t.points) do L._points[pt.nom] = pt.co end
          end
        end
      end

    elseif mode == "trace" then
      local repere_consomme = false
      if not frame and #acc == 0
         and line:match("^%s*un%s+repère%f[%s>]") then
        local t = P.translate_trace_phrase(trim(line))
        if t.kind == "frame" then frame = t; repere_consomme = true end
      end
      if repere_consomme then
      elseif line:match("^%s*}%s*$") then
        local head
        if frame then
          local extra = {}
          for _, w in ipairs(frame.opts or {}) do
            if w:match("^unit[xy]:") then extra[#extra + 1] = w end
          end
          head = "<draw axes:{" .. (frame.x or "{-5, 5}"):gsub("[{}]", "")
               .. ", " .. (frame.y or "{-5, 5}"):gsub("[{}]", "") .. "}"
               .. (#extra > 0 and (" " .. table.concat(extra, " ")) or "")
               .. ">"
        else
          head = "<draw>"
        end
        out[#out + 1] = head .. "{"
        if frame and L._points then
          local corps = table.concat(acc, "\n")
          local noms = {}
          for n in pairs(L._points) do noms[#noms + 1] = n end
          table.sort(noms)
          for _, n in ipairs(noms) do
            if corps:find("%f[%u]" .. n .. "%f[^%w]")
               and not corps:find("point%s+" .. n .. "%f[^%w]") then
              out[#out + 1] = "\tpoint " .. n .. " " .. L._points[n]
            end
          end
        end
        for _, l in ipairs(acc) do out[#out + 1] = "\t" .. l end
        out[#out + 1] = "}"
        mode, acc, frame = "normal", nil, nil
      else
        local phrase = trim(line)
        if phrase ~= "" then
          local t = P.translate_trace_phrase(phrase,
                      { frame = frame ~= nil, block = true })
          if t.kind == "draw_multi" then
            for _, l in ipairs(t.lines) do acc[#acc + 1] = l end
          else
            acc[#acc + 1] = t.line
          end
        end
      end
    end
  end

  return table.concat(out, "\n")
end

return L
