diff options
Diffstat (limited to 'biblio/citation-style-language/citeproc-element.lua')
-rw-r--r-- | biblio/citation-style-language/citeproc-element.lua | 744 |
1 files changed, 435 insertions, 309 deletions
diff --git a/biblio/citation-style-language/citeproc-element.lua b/biblio/citation-style-language/citeproc-element.lua index 0e7abf87e3..197616cb08 100644 --- a/biblio/citation-style-language/citeproc-element.lua +++ b/biblio/citation-style-language/citeproc-element.lua @@ -6,410 +6,536 @@ local element = {} -local unicode = require("unicode") +local SeqIr = require("citeproc-ir-node").SeqIr + +local InlineElement = require("citeproc-output").InlineElement +local Micro = require("citeproc-output").Micro -local richtext = require("citeproc-richtext") local util = require("citeproc-util") local Element = { - default_options = {}, + element_name = nil, + children = nil, + element_type_map = {}, } -function Element:new () - local o = {} +function Element:new(element_name) + local o = { + element_name = element_name or self.element_name, + } setmetatable(o, self) self.__index = self return o end -Element.option_type = { - ["et-al-min"] = "integer", - ["et-al-use-first"] = "integer", - ["et-al-subsequent-min"] = "integer", - ["et-al-subsequent-use-first"] = "integer", - ["near-note-distance"] = "integer", - ["line-spacing"] = "integer", - ["entry-spacing"] = "integer", - ["names-min"] = "integer", - ["names-use-first"] = "integer", - ["limit-day-ordinals-to-day-1"] = "boolean", - ["punctuation-in-quote"] = "boolean", - ["et-al-use-last"] = "boolean", - ["initialize"] = "boolean", - ["initialize-with-hyphen"] = "boolean", - ["disambiguate-add-names"] = "boolean", - ["disambiguate-add-givenname"] = "boolean", - ["disambiguate-add-year-suffix"] = "boolean", - ["hanging-indent"] = "boolean", - ["names-use-last"] = "boolean", - ["quotes"] = "boolean", - ["strip-periods"] = "boolean", -} +function Element:derive(element_name, default_options) + local o = { + element_name = element_name or self.element_name, + children = nil, + } -Element.inheritable_options = { - -- Style - ["initialize-with-hyphen"] = true, - ["page-range-format"] = true, - ["demote-non-dropping-particle"] = true, - -- Citation - ["disambiguate-add-givenname"] = true, - ["givenname-disambiguation-rule"] = true, - ["disambiguate-add-names"] = true, - ["disambiguate-add-year-suffix"] = true, - ["cite-group-delimiter"] = true, - ["collapse"] = true, - ["year-suffix-delimiter"] = true, - ["after-collapse-delimiter"] = true, - ["near-note-distance"] = true, - -- Bibliography - ["second-field-align"] = true, -- for use in layout - ["subsequent-author-substitute"] = true, - ["subsequent-author-substitute-rule"] = true, - -- Date - ["date-parts"] = true, - -- Names - ["and"] = true, - ["delimiter-precedes-et-al"] = true, - ["delimiter-precedes-last"] = true, - ["et-al-min"] = true, - ["et-al-use-first"] = true, - ["et-al-use-last"] = true, - ["et-al-subsequent-min"] = true, - ["et-al-subsequent-use-first"] = true, - ["names-min"] = true, - ["names-use-first"] = true, - ["names-use-last"] = true, - ["initialize-with"] = true, - ["name-as-sort-order"] = true, - ["sort-separator"] = true, - ["name-form"] = true, - ["name-delimiter"] = true, - ["names-delimiter"] = true, -} + if default_options then + for key, value in pairs(default_options) do + o[key] = value + end + end + + Element.element_type_map[element_name] = o + setmetatable(o, self) + self.__index = self + return o +end -function Element:render (item, context) - self:debug_info(context) - context = self:process_context(context) - return self:render_children(item, context) +function Element:from_node(node, parent) + local o = self:new() + o.element_name = self.element_name or node:get_element_name() + return o end -function Element:render_children (item, context) - local output = {} - for i, child in ipairs(self:get_children()) do - if child:is_element() then - if child.render == nil then - local element_name = child:get_element_name() - util.warning("Unkown type \"" .. element_name .. "\"") - end - local str = child:render(item, context) - table.insert(output, str) - end +function Element:set_attribute(node, attribute) + local value = node:get_attribute(attribute) + if value then + local key = string.gsub(attribute, "%-" , "_") + self[key] = value end - return self:concat(output, context) end -function Element:set_base_class (node) - if node:is_element() then - local org_meta_table = getmetatable(node) - setmetatable(node, {__index = function (_, key) - if self[key] then - return self[key] - else - return org_meta_table[key] - end - end}) +function Element:set_bool_attribute(node, attribute) + local value = node:get_attribute(attribute) + if value == "true" then + local key = string.gsub(attribute, "%-" , "_") + self[key] = true + elseif value == "false" then + local key = string.gsub(attribute, "%-" , "_") + self[key] = false end end -function Element:debug_info (context, debug) - -- debug = true - if debug then - local text = "" - local level = 0 - if context and context.level then - level = context.level + 1 - end - text = text .. string.rep(" ", 2 * level) - text = text .. self:get_element_name() - local attrs = {} - if self._attr then - for attr, value in pairs(self._attr) do - table.insert(attrs, attr .. "=\"" .. value .. "\"") - end - text = text .. "[" .. table.concat(attrs, " ") .. "]" - end - io.stderr:write(text .. "\n") +function Element:set_number_attribute(node, attribute) + local value = node:get_attribute(attribute) + if value then + local key = string.gsub(attribute, "%-" , "_") + self[key] = tonumber(value) end end -function Element:get_child (type) - for _, child in ipairs(self:get_children()) do - if child:get_element_name() == type then - return child +function Element:process_children_nodes(node) + if not self.children then + self.children = {} + end + for _, child in ipairs(node:get_children()) do + if child:is_element() then + local element_name = child:get_element_name() + local element_type = self.element_type_map[element_name] or Element + local child_element = element_type:from_node(child, self) + table.insert(self.children, child_element) end end - return nil -end -function Element:get_style () - local style = self:root_node().style - assert(style ~= nil) - return style end -function Element:get_engine () - local engine = self:root_node().engine - assert(engine ~= nil) - return engine +function Element:build_ir(engine, state, context) + return self:build_children_ir(engine, state, context) end -function Element:process_context (context) - local state = { - -- The `build` table is directly passed to new context. - build = context.build or {}, - -- The `option` table is copied. - options = {}, - -- Other items in `context` is copied. - } - for key, value in pairs(self.default_options) do - state.options[key] = value - end - if context then - local element_name = self:get_element_name() - for key, value in pairs(context) do - if key == "options" then - for k, v in pairs(context.options) do - if self.inheritable_options[k] then - state.options[k] = v - if element_name == "name" then - if k == "name-form" then - state.options["form"] = v - end - if k == "name-delimiter" then - state.options["delimiter"] = v - end - elseif element_name == "names" then - if k == "names-delimiter" then - state.options["delimiter"] = v - end - end - end +function Element:build_children_ir(engine, state, context) + local child_irs = {} + local ir_sort_key + local group_var = "plain" + if self.children then + for _, child_element in ipairs(self.children) do + local child_ir = child_element:build_ir(engine, state, context) + if child_ir then + if child_ir.sort_key ~= nil then + ir_sort_key = child_ir.sort_key end - else - state[key] = value + if child_ir.group_var == "important" then + group_var = "important" + end + table.insert(child_irs, child_ir) end end - if state.level then - state.level = state.level + 1 - else - state.level = 0 - end end - if self._attr then - for key, value in pairs(self._attr) do - if self.option_type[key] == "integer" then - value = tonumber(value) - elseif self.option_type[key] == "boolean" then - value = (value == "true") - end - state.options[key] = value - end + local ir = SeqIr:new(child_irs, self) + ir.sort_key = ir_sort_key + ir.group_var = group_var + if #child_irs == 0 then + ir.group_var = "missing" + else + ir.group_var = group_var end - return state + return ir end -function Element:get_option (key, context) - assert(context ~= nil) - return context.options[key] -end - -function Element:get_locale_option (key) - local locales = self:get_style():get_locales() - for i, locale in ipairs(locales) do - local option = locale:get_option(key) - if option ~= nil then - return option - end +-- Used in cs:group and cs:macro +function Element:build_group_ir(engine, state, context) + if not self.children then + return nil end - return nil -end + local irs = {} + local name_count + local ir_sort_key + local group_var = "plain" + + for _, child_element in ipairs(self.children) do + -- util.debug(child_element.element_name) + local child_ir = child_element:build_ir(engine, state, context) + -- util.debug(child_ir) + -- util.debug(child_ir.group_var) + + if child_ir then + -- cs:group and its child elements are suppressed if + -- a) at least one rendering element in cs:group calls a variable (either + -- directly or via a macro), and + -- b) all variables that are called are empty. This accommodates + -- descriptive cs:text and `cs:label` elements. + local child_group_var = child_ir.group_var + if child_group_var == "important" then + group_var = "important" + elseif child_group_var == "missing" and child_ir._type ~= "YearSuffix" then + if group_var == "plain" then + group_var = "missing" + end + end -function Element:get_variable (item, name, context) - if context.suppressed_variables and context.suppressed_variables[name] then - return nil - else - local res = item[name] - if type(res) == "table" and res._type == "RichText" then - -- TODO: should be deep copy - res = res:shallow_copy() - end + if child_ir.name_count then + if not name_count then + name_count = 0 + end + name_count = name_count + child_ir.name_count + end - if res and res ~= "" then - if context.suppress_subsequent_variables then - context.suppressed_variables[name] = true + if child_ir.sort_key ~= nil then + ir_sort_key = child_ir.sort_key end + + table.insert(irs, child_ir) end - return res end -end -function Element:get_macro (name) - local query = string.format("macro[name=\"%s\"]", name) - local macro = self:root_node():query_selector(query)[1] - if not macro then - error(string.format("Failed to find %s.", query)) + -- A non-empty nested cs:group is treated as a non-empty variable for the + -- puropses of determining suppression of the outer cs:group. + if #irs > 0 and group_var == "plain" then + group_var = "important" end - return macro -end -function Element:get_term (name, form, number, gender) - return self:get_style():get_term(name, form, number, gender) -end + local ir = SeqIr:new(irs, self) + ir.name_count = name_count + ir.sort_key = ir_sort_key + ir.group_var = group_var --- Formatting -function Element:escape (str, context) - return str - -- return self:get_engine().formatter.text_escape(str) + return ir end -function Element:format(text, context) - if not text or text == "" then - return nil +function Element:render_text_inlines(str, context) + if str == "" then + return {} end - if text._type ~= "RichText" then - text = richtext.new(text) + + str = self:apply_strip_periods(str) + -- TODO: try links + + local output_format = context.format + local localized_quotes = nil + if self.quotes then + localized_quotes = context:get_localized_quotes() end - local attributes = { + + local inlines = InlineElement:parse(str, context) + local is_english = context:is_english() + output_format:apply_text_case(inlines, self.text_case, is_english) + inlines = {Micro:new(inlines)} + inlines = output_format:with_format(inlines, self.formatting) + inlines = output_format:affixed_quoted(inlines, self.affixes, localized_quotes) + return output_format:with_display(inlines, self.display) +end + +function Element:set_formatting_attributes(node) + for _, attribute in ipairs({ "font-style", "font-variant", "font-weight", "text-decoration", "vertical-align", - } - for _, attribute in ipairs(attributes) do - local value = context.options[attribute] + }) do + local value = node:get_attribute(attribute) if value then - if text.formats[attribute] then - local new = richtext.new() - new.contents = {text} - text = new + if not self.formatting then + self.formatting = {} end - text:add_format(attribute, value) + self.formatting[attribute] = value end end - return text end --- Affixes -function Element:wrap (str, context) - if not str or str == "" then - return nil - end - local prefix = context.options["prefix"] - local suffix = context.options["suffix"] - local res = str - if prefix and prefix ~= "" then - local linkable = false - local variable_name = context.options["variable"] - if variable_name == "DOI" or variable_name == "PMID" or variable_name == "PMCID" then - linkable = true +function Element:set_affixes_attributes(node) + for _, attribute in ipairs({"prefix", "suffix"}) do + local value = node:get_attribute(attribute) + if value then + if not self.affixes then + self.affixes = {} + end + self.affixes[attribute] = value end - if variable_name == "URL" or (linkable and not string.match(prefix, "^https?://")) then - res:add_format(variable_name, "true") + end +end + +function Element:get_delimiter_attribute(node) + self:set_attribute(node, "delimiter") +end + +function Element:set_display_attribute(node) + self:set_attribute(node, "display") +end + +function Element:set_quotes_attribute(node) + self:set_bool_attribute(node, "quotes") +end + +function Element:set_strip_periods_attribute(node) + self:set_bool_attribute(node, "strip-periods") +end + +function Element:set_text_case_attribute(node) + self:set_attribute(node, "text-case") +end + +-- function Element:apply_formatting(ir) +-- local attributes = { +-- "font_style", +-- "font_variant", +-- "font_weight", +-- "text_decoration", +-- "vertical_align", +-- } +-- for _, attribute in ipairs(attributes) do +-- local value = self[attribute] +-- if value then +-- if not ir.formatting then +-- ir.formatting = {} +-- end +-- ir.formatting[attribute] = value +-- end +-- end +-- return ir +-- end + +function Element:apply_affixes(ir) + if ir then + if self.prefix then + ir.prefix = self.prefix end - res = richtext.concat(prefix, res) - if linkable and string.match(prefix, "^https?://") then - res:add_format("URL", "true") + if self.suffix then + ir.suffix = self.suffix end end - if suffix and suffix ~= "" then - res = richtext.concat(res, suffix) + return ir +end + +function Element:apply_delimiter(ir) + if ir and ir.children then + ir.delimiter = self.delimiter end - return res + return ir end --- Delimiters -function Element:concat (strings, context) - local delimiter = context.options["delimiter"] - return richtext.concat_list(strings, delimiter) +function Element:apply_display(ir) + ir.display = self.display + return ir end --- Display -function Element:display(text, context) - if not text then - return text - end - local value = context.options["display"] - if not value then - return text +function Element:apply_quotes(ir) + if ir and self.quotes then + ir.quotes = true + ir.children = {ir} + ir.open_quote = nil + ir.close_quote = nil + ir.open_inner_quote = nil + ir.close_inner_quote = nil + ir.punctuation_in_quote = false end - if type(text) == "string" then - text = richtext.new(text) + return ir +end + +function Element:apply_strip_periods(str) + local res = str + if str and self.strip_periods then + res = string.gsub(str, "%.", "") end - text:add_format("display", value) - return text + return res end --- Quotes -function Element:quote (str, context) - if not str then - return nil + +function Element:format_number(number, variable, form, context) + -- if not util.is_numeric(number) then + -- number = string.gsub(number, "\\%-", "-") + -- return number + -- end + number = util.strip(number) + if variable == "locator" then + variable = context:get_variable("label") end - if context.sorting then - return str + form = form or "numeric" + local number_part_list = self:split_number_parts(number, context) + -- { + -- {"1", "", " & "} + -- {"5", "8", ", "} + -- } + -- util.debug(number_part_list) + + for _, number_parts in ipairs(number_part_list) do + if form == "roman" then + self:format_roman_number_parts(number_parts) + elseif form == "ordinal" or form == "long-ordinal" then + local gender = context.locale:get_number_gender(variable) + self:format_ordinal_number_parts(number_parts, form, gender, context) + elseif number_parts[2] ~= "" and variable == "page" then + local page_range_format = context.style.page_range_format + self:format_page_range(number_parts, page_range_format) + else + self:format_numeric_number_parts(number_parts) + end end - if not str._type == "RichText" then - str = richtext.new(str) + + local range_delimiter = util.unicode["en dash"] + if variable == "page" then + local page_range_delimiter = context:get_simple_term("page-range-delimiter") + if page_range_delimiter then + range_delimiter = page_range_delimiter + end end - local quotes = context.options["quotes"] or false - if quotes then - str:add_format("quotes", "true") + + local res = "" + for _, number_parts in ipairs(number_part_list) do + res = res .. number_parts[1] + if number_parts[2] ~= "" then + res = res .. range_delimiter + res = res .. number_parts[2] + end + res = res .. number_parts[3] end - return str + return res + end --- Strip periods -function Element:strip_periods (str, context) - if not str then - return nil +function Element:split_number_parts(number, context) + -- number = string.gsub(number, util.unicode["en dash"], "-") + local and_symbol + and_symbol = context.locale:get_simple_term("and", "symbol") + if and_symbol then + and_symbol = " " .. and_symbol .. " " end - if str._type ~= "RichText" then - str = richtext.new(str) + local number_part_list = {} + for _, tuple in ipairs(util.split_multiple(number, "%s*[,&]%s*", true)) do + local single_number, delim = table.unpack(tuple) + delim = util.strip(delim) + if delim == "," then + delim = ", " + elseif delim == "&" then + delim = and_symbol or " & " + end + local start = single_number + local stop = "" + local splits = util.split(start, "%s*%-%s*") + if #splits == 2 then + start, stop = table.unpack(splits) + if util.endswith(start, "\\") then + start = string.sub(start, 1, -2) + start = start .. "-" .. stop + stop = "" + end + -- if string.match(start, "^%a*%d+%a*$") and string.match(stop, "^%a*%d+%a*$") then + -- if s + table.insert(number_part_list, {start, stop, delim}) + -- else + -- table.insert(number_part_list, {start .. "-" .. stop, "", delim}) + -- end + else + table.insert(number_part_list, {start, stop, delim}) + end end - local strip_periods = context.options["strip-periods"] - if strip_periods then - str:strip_periods() + return number_part_list +end + +function Element:format_roman_number_parts(number_parts) + for i = 1, 2 do + local part = number_parts[i] + if string.match(part, "%d+") then + number_parts[i] = util.convert_roman(tonumber(part)) + end end - return str end --- Text-case -function Element:case (text, context) - if not text or text == "" then - return nil +function Element:format_ordinal_number_parts(number_parts, form, gender, context) + for i = 1, 2 do + local part = number_parts[i] + if string.match(part, "%d+") then + local number = tonumber(part) + if form == "long-ordinal" and number >= 1 and number <= 10 then + number_parts[i] = context:get_simple_term(string.format("long-ordinal-%02d", number)) + else + local suffix = context.locale:get_ordinal_term(number, gender) + if suffix then + number_parts[i] = number_parts[i] .. suffix + end + end + end + end +end + +function Element:format_numeric_number_parts(number_parts) + -- if number_parts[2] ~= "" then + -- local first_prefix = string.match(number_parts[1], "^(.-)%d+") + -- local second_prefix = string.match(number_parts[2], "^(.-)%d+") + -- if first_prefix == second_prefix then + -- number_parts[1] = number_parts[1] .. "-" .. number_parts[2] + -- number_parts[2] = "" + -- end + -- end +end + +-- https://docs.citationstyles.org/en/stable/specification.html#appendix-v-page-range-formats +function Element:format_page_range(number_parts, page_range_format) + local start = number_parts[1] + local stop = number_parts[2] + local start_prefix, start_num = string.match(start, "^(.-)(%d+)$") + local stop_prefix, stop_num = string.match(stop, "^(.-)(%d+)$") + if start_prefix ~= stop_prefix then + -- Not valid range: "n11564-1568" -> "n11564-1568" + -- 110-N6 + -- N110-P5 + number_parts[1] = start .. "-" .. stop + number_parts[2] = "" + return end - if text._type ~= "RichText" then - text = richtext.new(text) + + if not page_range_format then + return end - local text_case = context.options["text-case"] - if not text_case then - return text + if page_range_format == "chicago-16" then + stop = self:_format_range_chicago_16(start_num, stop_num) + elseif page_range_format == "chicago-15" then + stop = self:_format_range_chicago_15(start_num, stop_num) + elseif page_range_format == "expanded" then + stop = stop_prefix .. self:_format_range_expanded(start_num, stop_num) + elseif page_range_format == "minimal" then + stop = self:_format_range_minimal(start_num, stop_num) + elseif page_range_format == "minimal-two" then + stop = self:_format_range_minimal(start_num, stop_num, 2) end - if text_case == "title" then - -- title case conversion only affects English-language items - local language = context.item["language"] - if not language then - language = self:get_style():get_attribute("default-locale") or "en-US" - end - if not util.startswith(language, "en") then - return text + number_parts[2] = stop +end + +function Element:_format_range_chicago_16(start, stop) + if #start < 3 or string.sub(start, -2) == "00" then + return self:_format_range_expanded(start, stop) + elseif string.sub(start, -2, -2) == "0" then + return self:_format_range_minimal(start, stop) + else + return self:_format_range_minimal(start, stop, 2) + end + return stop +end + +function Element:_format_range_chicago_15(start, stop) + if #start < 3 or string.sub(start, -2) == "00" then + return self:_format_range_expanded(start, stop) + else + local changed_digits = self:_format_range_minimal(start, stop) + if string.sub(start, -2, -2) == "0" then + return changed_digits + elseif #start == 4 and #changed_digits == 3 then + return self:_format_range_expanded(start, stop) + else + return self:_format_range_minimal(start, stop, 2) end end - text:add_format("text-case", text_case) - return text + return stop end +function Element:_format_range_expanded(start, stop) + -- Expand "1234–56" -> "1234–1256" + if #start <= #stop then + return stop + end + return string.sub(start, 1, #start - #stop) .. stop +end + +function Element:_format_range_minimal(start, stop, threshold) + threshold = threshold or 1 + if #start < #stop then + return stop + end + local offset = #start - #stop + for i = 1, #stop - threshold do + local j = i + offset + if string.sub(stop, i, i) ~= string.sub(start, j, j) then + return string.sub(stop, i) + end + end + return string.sub(stop, -threshold) +end element.Element = Element |