-- experimental / testing

file_start("par.lua")

local publisher = require("publisher")

---@class Par
---@field textformat? Textformat Resolved textformat of the paragraph.
---@field origin? string Caller identifier (debugging only).
---@field margin_top? integer Margin above the paragraph in sp.
---@field margin_bottom? integer Margin below the paragraph in sp.
---@field padding_left? integer Padding on the left in sp.
---@field padding_right? integer Padding on the right in sp.
---@field padding_top? integer Padding above the paragraph in sp.
---@field padding_bottom? integer Padding below the paragraph in sp.
---@field border_top_width? integer Width of the rule above the paragraph in sp.
---@field border_bottom_width? integer Width of the rule below the paragraph in sp.
---@field startborder? integer Index into `publisher.borderattributes` (border starts here).
---@field startendborder? integer Index into `publisher.borderattributes` (border starts and ends here).
---@field borderstart? integer Index into `publisher.borderattributes`.
---@field break_before? "page"|"always"|"" Break behaviour before this paragraph.
---@field break_after? string Break behaviour after this paragraph ("avoid").
---@field width? integer Requested width in sp.
---@field id? string Structure element id (PDF/UA).
---@field role? integer Structure role number (PDF/UA).
---@field parent? string Id of the parent structure element (PDF/UA).
---@field rolecounter? integer Counter for the structure role (PDF/UA).
---@field structpos? string Position in the parent structure element (PDF/UA).
---@field actualtext? string Replacement text for the structure element (PDF/UA).
---@field html? string HTML processing mode ("off" disables it).
---@field typ "par"
---@field has_color? boolean
---@field has_hyperlink? boolean
---@field has_role? boolean
---@field has_special_nodes? boolean
---@field fontfamily? integer Font family number for the whole paragraph.
---@field flatten_callback? fun(thiselt: table, options: table): Par Called during flatten to re-typeset the contents (see Nobreak).
local Par = {}

-- Constructs a fresh `Par` instance with the given textformat.
---@param self Par
---@param textformat? Textformat
---@param origin? string
---@return Par
function Par:new(textformat, origin)
    local instance = {
        textformat = textformat,
        origin = origin,
        typ = "par",
    }
    setmetatable(instance, self)
    self.__index = self
    for i = 1, #instance do
        instance[i] = nil
    end
    return instance
end

-- Adds glue of width `wd` at the end of every line in the linebroken
-- vbox `nl`. Used to honor padding-right without shrinking the line width.
---@param nl Node Vbox of lines.
---@param wd integer Padding in sp.
---@return Node nl
local function widen_nodelist(nl, wd)
    local glue = publisher.nodes.make_glue({ width = wd })
    local hbox = nl.head

    while hbox do
        if hbox.id == publisher.hlist_node then
            local tail = node.tail(hbox)
            node.insert_after(tail, tail, node.copy(glue))
            hbox.width = hbox.width + wd
        end
        hbox = hbox.next
    end
    nl.width = nl.width + wd
    return nl
end

-- Adds glue of width `wd` at the start of every line in the linebroken
-- vbox `nl`. Used to apply a left indent to a paragraph.
---@param nl Node Vbox of lines.
---@param wd integer Indent in sp.
---@return nil
local function indent_nodelist(nl, wd)
    local glue = publisher.nodes.make_glue({ width = wd })
    local hbox = nl.head
    while hbox do
        if hbox.id == publisher.hlist_node then
            hbox.head = node.insert_before(hbox.head, hbox.head, node.copy(glue))
            hbox.width = hbox.width + wd
        end
        hbox = hbox.next
    end
    nl.width = nl.width + wd
    return nl
end

local void_elements = {
    area = true,
    base = true,
    br = true,
    col = true,
    hr = true,
    img = true,
    input = true,
    link = true,
    meta = true,
    param = true,
    command = true,
    keygen = true,
    source = true,
}

-- Reconstructs the original (or close-to-original) HTML/CSS text for an
-- element so it can flow through the regular paragraph builder.
---@param elt table HTML element.
---@return string text
local function reconstruct_html_text(elt)
    local eltname = elt[".__local_name"]
    eltname = string.lower(eltname)
    local ret = {}
    table.insert(ret, "<")
    table.insert(ret, eltname)
    if elt[".__attributes"] then
        for key, value in pairs(elt[".__attributes"]) do
            table.insert(ret, string.format(' %s="%s"', key, publisher.xml_helpers.xml_escape(value)))
        end
    else
        for key, value in next, elt, nil do
            if type(key) == "string" and not string.match(key, "^.__") then
                table.insert(ret, string.format(' %s="%s"', key, publisher.xml_helpers.xml_escape(value)))
            end
        end
    end
    if #elt == 0 and void_elements[eltname] then
        table.insert(ret, ">")
    else
        table.insert(ret, ">")
        for i = 1, #elt do
            local thiselt = elt[i]
            local type_thiselt = type(thiselt)
            if type_thiselt == "string" then
                table.insert(ret, publisher.xml_helpers.xml_escape(thiselt))
            elseif type_thiselt == "table" then
                table.insert(ret, reconstruct_html_text(thiselt))
            end
        end

        table.insert(ret, "</")
        table.insert(ret, eltname)
        table.insert(ret, ">")
    end
    return table.concat(ret)
end

-- Builds a glyph node list from `text` using the surrounding par's
-- options merged with `options`.
---@param self Par
---@param text string
---@param options table Per-call style overrides.
---@return Node head
local function mktextnode(self, text, options)
    local nodes, newdir = publisher.nodes.mknodes(tostring(text), options, "par/mktextnode")
    if options.fontoutlinewidth and options.fontoutlinewidth > 0 then
        local pw = node.new("whatsit", "pdf_literal")
        pw.data = string.format(" 1 Tr %g w ", sp_to_bp(options.fontoutlinewidth))
        nodes = node.insert_before(nodes, nodes, pw)

        pw = node.new("whatsit", "pdf_literal")
        pw.data = " 0 Tr "
        nodes = node.insert_after(nodes, node.tail(nodes), pw)
    end

    self.direction = self.direction or newdir
    if options.fontfamily and publisher.fonts.lookup_fontfamily_number_instance[options.fontfamily] then
        local node_properties = node.getproperty(nodes)
        local fontheight = publisher.fonts.lookup_fontfamily_number_instance[options.fontfamily].baselineskip
        local col = publisher.attribute_helpers.get_attribute(nodes, "color")
        local hl = publisher.attribute_helpers.get_attribute(nodes, "hyperlink")
        nodes = publisher.nodes.add_rule(
            nodes,
            "head",
            { height = 0.75 * fontheight, depth = 0.25 * fontheight, width = 0 },
            "mktextnode fontfamily"
        )
        node.setproperty(nodes, node_properties)
        publisher.attribute_helpers.set_attribute(nodes, "fontfamily", options.fontfamily)
        if col then
            publisher.attribute_helpers.set_attribute(nodes, "color", col)
        end
        if hl then
            publisher.attribute_helpers.set_attribute(nodes, "hyperlink", hl)
        end
    end
    if options.newline then
        publisher.attribute_helpers.setprop(nodes, "newline", true)
        if options.discardallowed then
            publisher.attribute_helpers.setprop(nodes, "discardallowed", true)
        end
    end
    return nodes
end

-- Flattens a heterogeneous list of paragraph items (text, marks, hyperlinks,
-- inline objects, function callbacks, ...) into the par's underlying array
-- of nodes, propagating style options downward.
---@param self Par
---@param items table Flat or nested list of items to append.
---@param options? table Inherited style options.
---@param data? table Data XML context (required for HTML contents).
---@return table items The `items` table, mutated in place.
local function flatten(self, items, options, data)
    options = options or {}
    local ret = {}
    local ret_len = 0
    local opts_pool = {}
    local function take_options(src)
        local opts = table.remove(opts_pool) or {}
        for k in pairs(opts) do
            opts[k] = nil
        end
        for k, v in next, src, nil do
            opts[k] = v
        end
        return opts
    end
    local function release_options(opts)
        for k in pairs(opts) do
            opts[k] = nil
        end
        opts_pool[#opts_pool + 1] = opts
    end
    local function append(v)
        ret_len = ret_len + 1
        ret[ret_len] = v
    end
    local roles_a = publisher.roles_a
    local pdf_ua = publisher.options.format == "PDF/UA"
    local text_options_shared
    local items_len = #items
    -- Cache type strings to avoid repeated string comparisons
    local type_string = "string"
    local type_number = "number"
    local type_boolean = "boolean"
    local type_table = "table"
    local type_userdata = "userdata"
    local type_function = "function"
    local default_css_text = publisher.css:gettext()
    -- Feature flag tracking: detect which features are used so that
    -- insert_nonmoving_whatsits can skip clean paragraphs
    local function mark_color()
        self.has_color = true
    end
    local function mark_hyperlink()
        self.has_hyperlink = true
    end
    local function mark_role()
        self.has_role = true
    end
    local function mark_special()
        self.has_special_nodes = true
    end

    for i = 1, items_len do
        local thisself = items[i]
        local typ_thisself = type(thisself)
        local reuse_text_opts = (
            typ_thisself == type_string
            or typ_thisself == type_number
            or typ_thisself == type_boolean
        )
        local new_options
        if reuse_text_opts and text_options_shared then
            new_options = text_options_shared
        else
            new_options = take_options(options)
            if pdf_ua then
                if options.role == 0 then
                    new_options.role = options.parentrole
                    new_options.id = roles_a[options.parentrole] .. "_" .. tostring(options.rolecounter)
                elseif options.role then
                    new_options.id = roles_a[options.role] .. "_" .. tostring(options.rolecounter)
                    new_options.parent = options.parent
                else
                    new_options.id = self.id
                end
                new_options.parentrole = options.role
                new_options.parentid = options.id
            end
            new_options.direction = new_options.direction or self.direction
            local effective_textformat = self.textformat or options.textformat
            if not new_options.letterspacing and effective_textformat and effective_textformat.letterspacing then
                new_options.letterspacing_em = effective_textformat.letterspacing
            end
            if reuse_text_opts then
                text_options_shared = new_options
            end
        end
        if typ_thisself == type_table and thisself.contents then
            -- w("par/flatten: type: table with contents")
            if thisself.options then
                for key, value in next, thisself.options, nil do
                    new_options[key] = value
                end
            end
            if new_options.padding_left and not self.padding_left then
                self.padding_left = new_options.padding_left
            end
            if node.is_node(thisself.contents) then
                if
                    thisself.contents.id == publisher.whatsit_node
                    and thisself.contents.subtype == publisher.user_defined_whatsit
                then
                    mark_special()
                    if type(thisself.contents.value) == "function" then
                        -- leaders and break_url
                        append(thisself.contents.value(new_options))
                    else
                        append(thisself.contents)
                    end
                    -- action node for example
                else
                    -- Direct node with potentially pre-set attributes (e.g. from HTML/CSS)
                    mark_special()
                    append(thisself.contents)
                end
            elseif type(thisself.contents) == type_table and thisself.contents.flatten_callback then
                mark_special()
                local f = thisself.contents.flatten_callback
                thisself.contents.flatten_callback = nil
                local tmp = f(thisself.contents, new_options)
                for j = 1, #tmp.objects do
                    append(tmp.objects[j])
                end
            elseif
                type(thisself.contents) == type_string
                or type(thisself.contents) == type_number
                or type(thisself.contents) == type_boolean
            then
                if new_options.color and new_options.color ~= 1 then
                    mark_color()
                end
                if new_options.hyperlink then
                    mark_hyperlink()
                end
                if new_options.role then
                    mark_role()
                end
                append(mktextnode(self, thisself.contents, new_options))
            else
                local tmp = flatten(self, thisself.contents, new_options, data)
                for j = 1, #tmp do
                    append(tmp[j])
                end
            end
        elseif typ_thisself == type_string or typ_thisself == type_number or typ_thisself == type_boolean then
            -- w("par/flatten: type: string or similar")
            if new_options.color and new_options.color ~= 1 then
                mark_color()
            end
            if new_options.hyperlink then
                mark_hyperlink()
            end
            if new_options.role then
                mark_role()
            end
            append(mktextnode(self, thisself, new_options))
        elseif typ_thisself == type_table and thisself[".__type"] == "element" and new_options.html ~= "off" then
            -- HTML content can have any features, mark conservatively
            mark_color()
            mark_hyperlink()
            mark_role()
            mark_special()
            -- w("par/flatten: type: HTML")
            -- Now this is a bit strange and I should explain. The XML parser
            -- creates a table structure from the XML text, but for HTML parsing, we need the
            -- original XML string. So I reconstruct the XML text (without comments etc.) and
            -- run this through Go's HTML parser and add CSS.
            -- This is basically the new HTML mode. The old HTML parser is not needed anymore.
            if new_options.html == "inner" then
                local c = 1
                while true do
                    if #thisself > c and type(thisself[1]) ~= "table" then
                        c = c + 1
                    else
                        break
                    end
                end
                thisself = thisself[c]
            end
            if type(thisself) == type_string then
                local text = thisself
                local tmp = flatten(self, { text }, new_options, data)
                for j = 1, #tmp do
                    append(tmp[j])
                end
            else
                local htmltext = reconstruct_html_text(thisself)
                local csstext = " a { text-decoration: none; color: black}" .. default_css_text

                if self.textformat and self.textformat.cssfontsize == true then
                    -- ignore
                else
                    csstext = csstext .. string.format(" body {font-family-number: %d ;}", options.fontfamily)
                end
                local htmltree = splib.parse_html_text(htmltext, csstext)
                if not htmltree then
                    main.log("error", "Could not parse the HTML text")
                else
                    local body = htmltree[1][2]
                    body.block = nil
                    local startnewline = 0
                    local firstelement = body[1]
                    if firstelement then
                        if type(firstelement) == "string" and not string.match(firstelement, "^%s*$") then
                            startnewline = 1
                        elseif type(firstelement[1]) == "string" and not string.match(firstelement[1], "^%s*$") then
                            startnewline = 1
                        elseif type(firstelement[1]) == "table" then
                            if firstelement[1].direction == "→" then
                                startnewline = 1
                            end
                        end
                        options.override_alignment = true
                        assert(data, "HTML contents needs the data XML context")
                        local blocks = publisher.nodes.parse_html(htmltree, new_options, data) or {}
                        blocks = publisher.flatten_boxes(blocks)
                        -- printtable("blocks",blocks)

                        -- block number width contents
                        local blocknumber = 1
                        for b = 1, #blocks do
                            local thisblock = blocks[b]
                            local this_block_has_contents = false
                            for tb = 1, #thisblock do
                                local tbc = thisblock[tb].contents
                                local dir = publisher.attribute_helpers.getprop(tbc, "direction")
                                local mode = thisblock.mode
                                local startblock = (tb == 1 and mode == "block")
                                local is_newline = (
                                    blocknumber > startnewline
                                    and (this_block_has_contents == false)
                                    and dir ~= "→"
                                )
                                if tbc then
                                    if startblock or is_newline then
                                        publisher.attribute_helpers.setprop(tbc, "split", true)
                                        local padding_left = thisblock.padding_left or 0
                                        local margin_top = thisblock.margin_top or 0
                                        publisher.attribute_helpers.setprop(
                                            tbc,
                                            "prependnodelist",
                                            thisblock.prependnodelist
                                        )
                                        publisher.attribute_helpers.setprop(tbc, "prependlist", thisblock.prependlist)
                                        publisher.attribute_helpers.setprop(
                                            tbc,
                                            "margin_bottom",
                                            thisblock.margin_bottom
                                        )
                                        if thisblock.startendborder then
                                            local border_attributes =
                                                publisher.borderattributes[thisblock.startendborder]
                                            publisher.attribute_helpers.set_attribute(
                                                tbc,
                                                "bordernumber",
                                                thisblock.startendborder
                                            )
                                            local wd, ht, dp = node.dimensions(tbc)
                                            publisher.attribute_helpers.set_attribute(tbc, "borderwd", wd)
                                            publisher.attribute_helpers.set_attribute(tbc, "borderht", ht + dp)
                                            if border_attributes then
                                                padding_left = padding_left + border_attributes.border_left_width
                                                margin_top = margin_top
                                                    + border_attributes.border_top_width
                                                    + border_attributes.padding_top
                                            end
                                        end
                                        publisher.attribute_helpers.setprop(tbc, "margin_top", margin_top)
                                        publisher.attribute_helpers.setprop(tbc, "padding_left", padding_left)
                                    end
                                    append(tbc)
                                    this_block_has_contents = true
                                end
                            end
                            if this_block_has_contents then
                                blocknumber = blocknumber + 1
                            end
                        end
                    end
                end
            end
        elseif typ_thisself == type_userdata and node.is_node(thisself) then
            -- w("par/flatten: type: userdata")
            if thisself.id == publisher.whatsit_node and thisself.subtype == publisher.user_defined_whatsit then
                mark_special()
                if type(thisself.value) == type_function then
                    -- leaders and break_url
                    append(thisself.value(new_options))
                else
                    append(thisself)
                end
            else
                -- Direct node with potentially pre-set attributes (e.g. from HTML/CSS)
                mark_special()
                append(thisself)
            end
        elseif typ_thisself == type_table and thisself.elementname == "SetVariable" then
            -- w("par/flatten: type: setvariable - ignore")
            -- ignore
        elseif typ_thisself == type_table then
            -- w("par/flatten: type: table")
            local tmp
            local x, msg = publisher.xpath.string_value(thisself)
            if msg then
                main.log("error", msg)
            end
            tmp = flatten(self, { x }, new_options, data)
            for j = 1, #tmp do
                append(tmp[j])
            end
        else
            -- w("par/flatten: type: unknown")
            -- w("typ_thisself %s",typ_thisself)
        end
        if not (reuse_text_opts and new_options == text_options_shared) then
            release_options(new_options)
        end
    end
    -- Optimized: directly overwrite items, then clear extras
    for i = 1, ret_len do
        items[i] = ret[i]
    end
    -- Only clear items beyond ret_len
    for i = ret_len + 1, items_len do
        items[i] = nil
    end
    return items
end

-- Prepends an item (or list of items) to the par.
---@param self Par
---@param whatever any|any[]
---@return nil
function Par:prepend(whatever)
    self.prependlist = self.prependlist or {}
    table.insert(self.prependlist, 1, whatever)
end

-- Stores a left-indent value to be applied during `format()`.
---@param self Par
---@param width_sp integer Indent in sp.
---@return nil
function Par:indent(width_sp)
    -- w("indent %s wd %gpt",self.origin or "?", width_sp / publisher.factor)
    self.padding_left = self.padding_left or 0
    self.padding_left = self.padding_left + width_sp
end

-- Returns the minimum width the paragraph would occupy, defined as the
-- width of its widest non-breakable item.
---@param self Par
---@param textformat_name string Textformat used for the simulation.
---@param options table
---@param data table Data XML context.
---@return integer min_width Width in sp.
function Par:min_width(textformat_name, options, data)
    options = options or {}
    local newpar = publisher.utilities.deepcopy(self)
    newpar.origin = "min_width"
    options = options or {}
    local new_options = publisher.utilities.copy_table_from_defaults(options)
    new_options.textformat = textformat_name

    local formatted = newpar:format(1, new_options, data)
    local head = formatted.head
    if not head then
        return 0
    end
    -- See bug #46: a text format margin-top has a glue as its first item in the vlist
    while head and head.id ~= publisher.hlist_node do
        head = head.next
    end
    if not head then
        return 0
    end
    local _w
    local max = 0
    while head do
        if head.head then
            _w = node.dimensions(formatted.glue_set, formatted.glue_sign, formatted.glue_order, head.head)
            max = math.max(max, _w)
        end
        head = head.next
    end

    node.flush_list(formatted)
    return max
end

-- Returns the natural unbroken width and line height of the paragraph.
---@param self Par
---@param options table
---@param data table Data XML context.
---@return integer max_width Width in sp.
---@return integer lineheight Line height in sp.
function Par:max_width_and_lineheight(options, data)
    local newpar = publisher.utilities.deepcopy(self)
    newpar.origin = "max_width_and_lineheight"
    local orig_letterspacing = newpar.textformat and newpar.textformat.letterspacing
    newpar.textformat = nil
    options = options or {}
    local new_options = publisher.utilities.copy_table_from_defaults(options)
    new_options.textformat = publisher.textformats["__leftaligned"]
    if orig_letterspacing then
        new_options.letterspacing_em = orig_letterspacing
    end
    local nl = newpar:format(publisher.maxdimen, new_options, data)
    local maxwd = 0
    local hlist = nl.head
    while hlist do
        if hlist.id == publisher.hlist_node then
            -- could also be a glue node
            local wd = node.dimensions(hlist.head, node.tail(hlist.head))
            maxwd = math.max(maxwd, wd)
        end
        hlist = hlist.next
    end
    return maxwd, nl.height + nl.depth
end

-- Builds the glyph node list for the paragraph (without line breaking).
-- The result is stored in `self.objects`.
---@param self Par
---@param options table
---@param data? table Data XML context (required for HTML contents).
---@return nil
function Par:mknodelist(options, data)
    flatten(self, self, options, data)
    local nodelist
    local objects = {}
    for i = 1, #self do
        local thisself = self[i]
        if nodelist == nil then
            -- the beginning of a new line (perhaps the first new line)
            nodelist = thisself
        elseif
            (
                thisself.id == publisher.vlist_node
                and publisher.attribute_helpers.getprop(thisself, "origin") ~= "image"
                and not publisher.attribute_helpers.getprop(thisself, "inline")
            ) or publisher.attribute_helpers.getprop(thisself, "split")
        then
            -- text right after a  newline, so push stuff that we have into the objects list and
            -- put what we have into the node list
            if nodelist.id == publisher.glue_node and nodelist.prev == nil and nodelist.next == nil then
                -- ignore, just glue
            else
                table.insert(objects, nodelist)
            end
            nodelist = thisself
        else
            -- just objects to be appended to the node list
            local tail = node.tail(nodelist)
            -- Remove strut rule between two glues inline at join points
            -- (strut rules from mktextnode are always at the fragment head).
            if
                self.html == "off"
                and thisself.id == publisher.rule_node
                and tail.id == publisher.glue_node
                and thisself.next
                and thisself.next.id == publisher.glue_node
            then
                local after_rule = thisself.next
                after_rule.prev = nil
                thisself.next = nil
                node.free(thisself)
                thisself = after_rule
            end
            tail.next = thisself
            thisself.prev = tail
        end
    end
    -- insert the last fragment as an object of its own (new line)
    -- or add it to the last object of the table
    if nodelist then
        local split = publisher.attribute_helpers.getprop(nodelist, "split")
        if #objects > 0 and not split then
            local tail = node.tail(objects[#objects])
            -- Remove strut rule between two glues at final join point
            if
                self.html == "off"
                and nodelist.id == publisher.rule_node
                and tail.id == publisher.glue_node
                and nodelist.next
                and nodelist.next.id == publisher.glue_node
            then
                local after_rule = nodelist.next
                after_rule.prev = nil
                nodelist.next = nil
                node.free(nodelist)
                nodelist = after_rule
            end
            tail.next = nodelist
            nodelist.prev = tail
        else
            table.insert(objects, nodelist)
        end
    end
    self.objects = objects
end

local get_lineheight
-- Returns the maximum line height in `nodelist` (a vbox of lines).
---@param nodelist Node
---@return integer lineheight Line height in sp.
function get_lineheight(nodelist)
    local head = nodelist
    while head do
        if head.id == publisher.vlist_node or head.id == publisher.hlist_node then
            return get_lineheight(head.list)
        end
        if head.id == publisher.glyph_node then
            local ffnumber = publisher.attribute_helpers.get_attribute(head, "fontfamily")
            local fi = publisher.fonts.lookup_fontfamily_number_instance[ffnumber]
            if fi then
                return fi.baselineskip
            else
                main.log("error", "allocate/auto cannot find font instance")
                return 0
            end
        end
        head = head.next
    end
    return 0
end

-- Computes the border-width contribution, content height and margin-top
-- for a node list with HTML border properties attached.
---@param nodelist Node
---@return integer border_width Total border width in sp.
---@return integer height Content height in sp.
---@return integer margin_top sp.
local function get_border_width_height_margintop(nodelist)
    local sum_ht = 0
    local sum_margin_top = 0
    local head = nodelist.head
    while head do
        if head.id == publisher.glue_node then
            local margintop = publisher.attribute_helpers.get_attribute(head, "margintop")
            local paddingtop = publisher.attribute_helpers.get_attribute(head, "paddingtop")
            local paddingbottom = publisher.attribute_helpers.get_attribute(head, "paddingbottom")
            if margintop then
                sum_margin_top = sum_margin_top + head.width
            elseif paddingtop or paddingbottom then
                sum_ht = sum_ht + head.width
            end
        elseif node.has_field(head, "height") then
            sum_ht = sum_ht + head.height + head.depth
        end
        head = head.next
    end
    return nodelist.width, sum_ht, sum_margin_top
end

-- Builds the glyph node list, applies the textformat, runs line breaking
-- and returns the linebroken vbox. The main entry point used by callers
-- once they know the target width.
---@param self Par
---@param width_sp integer Target line width in sp.
---@param options? table Style overrides.
---@param data? table Data XML context.
---@return Node vbox Linebroken paragraph.
function Par:format(width_sp, options, data)
    -- w("call format %s",self.origin)
    options = options or {}
    options.maxwidth_sp = width_sp

    -- If this par contains a single preformatted node (e.g., HTML table),
    -- return it directly without wrapping in another vpack
    if #self == 1 and self[1].options and self[1].options.dontformat then
        local content = self[1].contents
        if node.is_node(content) then
            return content
        end
    end

    publisher.nodes.remove_first_whitespace(self)
    publisher.nodes.remove_last_whitespace(self)

    local current_textformat = self.textformat or options.textformat
    if not current_textformat then
        if self.textformat or options.textformat then
            main.log("error", "textformat undefined, using text instead")
        end
        current_textformat = publisher.textformats.text
    end
    options.tab = current_textformat.tab

    self:mknodelist(options, data)

    -- Check if the only object has att_dont_format attribute (e.g., split HTML table)
    -- In this case, return it directly without further formatting
    if #self.objects == 1 then
        local obj = self.objects[1]
        if node.is_node(obj) and node.has_attribute(obj, publisher.att_dont_format) then
            return obj
        end
    end

    local parameter = {}
    width_sp = self.width or width_sp
    local padding_left = self.padding_left or 0
    local padding_right = self.padding_right or 0
    self.padding_left = padding_left
    self.padding_right = padding_right
    if padding_left > 0 then
        width_sp = width_sp - padding_left
    end
    if padding_right > 0 then
        width_sp = width_sp - padding_right
    end
    -- w("self.padding_right %s %gpt",self.origin, self.padding_right / publisher.factor)
    -- w("self.padding_left %s %gpt",self.origin, self.padding_left / publisher.factor)
    for i = 1, #self do
        self[i] = nil
    end

    if options.allocate == "auto" then
        local indent = current_textformat.indent
        local indent_this_row = function(row)
            if not indent or indent == 0 then
                return false
            end
            local r = current_textformat.rows
            if r == 0 then
                return false
            end
            if r < 0 then
                return row > r * -1
            end
            return row <= r
        end
        local set_parshape = function(parshape, ps, rows)
            local psmin = {}
            for _, row in ipairs(rows) do
                if parshape[row] then
                    local tmp = parshape[row]
                    psmin[1] = math.max(ps[1], tmp[1])
                    psmin[2] = math.min(ps[2], tmp[2])
                    parshape[row] = psmin
                else
                    parshape[row] = { ps[1], ps[2] - self.padding_left - self.padding_right }
                end
            end
        end
        local is_equal = function(a, b)
            return math.abs(a - b) < 3000
        end
        -- First we need to get the starting page
        local current_pagenumber = publisher.current_pagenumber
        local areaname = options.area

        local frame, _ = publisher.pages[current_pagenumber].grid:get_advanced_cursor(areaname)
        if frame == publisher.maxframes then
            -- signal for "page is full"
            current_pagenumber = current_pagenumber + 1
        end
        if not publisher.pages[current_pagenumber] then
            -- it might be that the page is full and there is no next page
            -- then we set maxparshape to {0,maxwd} later on
            current_pagenumber = current_pagenumber - 1
        end
        -- Get the par shape
        local lineheight = get_lineheight(self.objects[1])
        if lineheight > 0 then
            local cg = assert(publisher.current_grid)
            local max_width = math.min(width_sp, cg:width_sp(cg:number_of_columns(areaname)))

            local gridheight = cg.gridheight
            local parshape = {}
            local maxframes = cg:number_of_frames(areaname)

            -- this is to remove rounding errors
            local g_l = math.round(gridheight / lineheight, 3)
            gridheight = lineheight * g_l

            local accumulated_height

            -- The row for the paragraph shape. Not identical to the grid row
            local current_row = 1
            local grid_row
            local lowest_grid_row = 0

            -- There might be material on one of the next pages. In this case,
            -- and only in this case, the next page is already allocated
            -- See bug #75 on github
            local maxparshape
            while publisher.pages[current_pagenumber] do
                if publisher.current_group then
                    cg = assert(publisher.current_grid)
                else
                    cg = publisher.pages[current_pagenumber].grid
                end
                local grid_lower
                local framenumber, startrow_grid = cg:get_advanced_cursor(areaname)
                -- Let's assume that the already typeset text ends at the next page
                -- This is not a real fix, but good enough for the moment.
                -- We need to fix the output/text collect routine
                -- and typeset the text directly. See #100
                if framenumber > maxframes then
                    -- w("framenumber %d > maxframes %d",framenumber,maxframes)
                    current_pagenumber = current_pagenumber + 1
                    maxparshape = { 0, max_width }

                    framenumber = 1
                    startrow_grid = 1
                else
                    maxparshape = nil
                end
                while framenumber <= maxframes do
                    grid_row = startrow_grid
                    accumulated_height = lowest_grid_row
                    grid_lower = lowest_grid_row + gridheight
                    lowest_grid_row = lowest_grid_row + cg:number_of_rows(areaname) * gridheight
                    while grid_row <= cg:number_of_rows(areaname, framenumber) do
                        local rows = {}
                        -- maxparshape is only "active" when placed on future, non-initialized pages
                        -- Hack!
                        local ps = maxparshape or cg:get_parshape(grid_row, areaname, framenumber, width_sp)
                        -- ps is 0 when the line is completely allocated
                        if ps ~= 0 then
                            -- accumulated_height starts with 0
                            if accumulated_height <= grid_lower then
                                -- When this paragraph row is within the grid row,
                                -- it must be added to our list
                                rows[#rows + 1] = current_row
                            end

                            while accumulated_height <= grid_lower do
                                if is_equal(accumulated_height + lineheight, grid_lower) then
                                    -- if the current paragraph row ends "exactly" at the
                                    -- bottom of the grid line, we are done and can continue
                                    -- with the next paragraph row. The current paragraph row is
                                    -- already added to the list for this grid row (see above)
                                elseif accumulated_height + lineheight < grid_lower then
                                    -- if the current paragraph row ends above the lower
                                    -- grid line, we need to add the next row to the
                                    -- current grid line.
                                    rows[#rows + 1] = current_row + 1
                                else
                                    -- This is the case where the current paragraph row ends
                                    -- below the lower grid line. We don't need to increase
                                    -- the paragraph line number and the accumulated
                                    -- height, so we break out of the while loop
                                    break
                                end

                                current_row = current_row + 1
                                accumulated_height = accumulated_height + lineheight
                            end
                            set_parshape(parshape, ps, rows)
                            grid_lower = grid_lower + gridheight
                        end -- if ps ~= 0
                        grid_row = grid_row + 1
                    end
                    startrow_grid = 1
                    framenumber = framenumber + 1
                end
                current_pagenumber = current_pagenumber + 1
            end
            -- This should be the last line in the parshape array, so the
            -- rest of the lines in the paragraph have the full width
            parshape[#parshape + 1] = { 0, max_width }
            for i, ps in ipairs(parshape) do
                if indent_this_row(i) then
                    ps[1] = ps[1] + indent
                    ps[2] = ps[2] - indent
                end
            end
            parameter.parshape = parshape
        end
    end
    local objects = self.objects
    local orig_width_sp = width_sp
    local objects_len = #objects
    if objects_len == 0 then
        return node.new("vlist")
    end
    local objectrow = 0

    -- Cache frequently accessed self fields
    local self_padding_left = self.padding_left or 0
    local self_padding_right = self.padding_right or 0
    local self_prependlist = self.prependlist
    local self_initial = self.initial
    local self_startendborder = self.startendborder
    local self_startborder = self.startborder
    local self_margin_top = self.margin_top
    local self_border_top_width = self.border_top_width
    local self_padding_top = self.padding_top

    -- Cache publisher node/attribute IDs
    local penalty_node_id = publisher.penalty_node
    local hlist_node_id = publisher.hlist_node
    local glue_node_id = publisher.glue_node
    local att_rows = publisher.att_rows
    local att_ignore_orphan = publisher.att_ignore_orphan_widowsetting
    local att_break_below = publisher.att_break_below_forbidden
    local att_margin_top_boxstart = publisher.att_margin_top_boxstart

    local tf = current_textformat
    for i = 1, objects_len do
        local nodelist = objects[i]
        if publisher.attribute_helpers.getprop(nodelist, "br") == true then
            tf = publisher.dispatch.new_textformat("", current_textformat)
            tf.rows = 9999
            tf.margintop = current_textformat.margintop
            tf.indent = current_textformat.indent
        else
            objectrow = objectrow + 1
        end
        -- Cache property accesses at loop start
        local prop_pardir = publisher.attribute_helpers.getprop(nodelist, "pardir")
        local prop_margin_top, prop_margin_bottom, prop_padding_left
        local tf_htmlvspace = tf.htmlverticalspacing
        if tf_htmlvspace == "inner" and i > 1 or tf_htmlvspace == "all" then
            prop_margin_top = publisher.attribute_helpers.getprop(nodelist, "margin_top")
        end
        if tf_htmlvspace == "inner" and i < objects_len or tf_htmlvspace == "all" then
            prop_margin_bottom = publisher.attribute_helpers.getprop(nodelist, "margin_bottom")
        end
        prop_padding_left = publisher.attribute_helpers.getprop(nodelist, "padding_left")
        local prop_prependlist = publisher.attribute_helpers.getprop(nodelist, "prependlist")

        if prop_pardir == "rtl" then
            tex.shapemode = 1
            parameter.pardir = "TRT"
        else
            tex.shapemode = 0
        end
        local has_margin_top = prop_margin_top
        local has_margin_bottom = prop_margin_bottom
        width_sp = orig_width_sp
        local thispaddingleft = self_padding_left
        local thispaddingright = self_padding_right
        local this_object_padding_left = prop_padding_left
        if this_object_padding_left then
            thispaddingleft = thispaddingleft + this_object_padding_left
            width_sp = width_sp - this_object_padding_left
        end
        local langs_num, langs
        langs = {}
        if tf.hyphenchar then
            langs_num = publisher.language.get_languages_used(nodelist)
            for _, v in ipairs(langs_num) do
                local l = publisher.language.get_language(v)
                langs[#langs + 1] = l
                l.prehyphenchar = lang.prehyphenchar(l.l)
                lang.prehyphenchar(l.l, unicode.utf8.byte(tf.hyphenchar))
            end
        end

        -- see #338 - penalty - hlist - penalty gives an error "Assertion ``varmem[(o)].hh.v.RH == cur_p`` failed"
        if
            nodelist.id == penalty_node_id
            and nodelist.next
            and nodelist.next.id == hlist_node_id
            and nodelist.next.next
            and nodelist.next.next.id == penalty_node_id
            and nodelist.next.next.next
        then
            nodelist = nodelist.next
        end
        publisher.fonts.pre_linebreak(nodelist)

        -- both are set only for ul/ol lists
        local indent = publisher.attribute_helpers.get_attribute(nodelist, "indent") or 0
        local rows = node.has_attribute(nodelist, att_rows)
        parameter.hangindent = indent

        -- indent and rows
        if tf.indent and tf.rows then
            if objectrow <= tf.rows or tf.rows < 0 then
                parameter.hangindent = parameter.hangindent + tf.indent
            end
        end
        parameter.hangafter = rows or tf.rows or 0
        if self_startendborder or self_startborder then
            local ba = publisher.borderattributes[self.borderstart or self_startendborder]
            thispaddingleft = thispaddingleft + ba.border_left_width
        end

        if self_initial then
            parameter.hangindent = parameter.hangindent + self_initial.width
            local i_ht = self_initial.height + self_initial.depth
            local ht_nodelist = get_lineheight(nodelist)

            local maxindent = 0
            -- get max indent
            if parameter.parshape then
                for row = 1, math.round(i_ht / ht_nodelist, 0) do
                    maxindent = math.max(parameter.parshape[row][1], maxindent)
                end
            end
            local curindent
            if parameter.parshape then
                for row = 1, math.round(i_ht / ht_nodelist, 0) do
                    curindent = maxindent - parameter.parshape[row][1]
                    parameter.parshape[row][1] = maxindent + self_initial.width
                    parameter.parshape[row][2] = parameter.parshape[row][2] - self_initial.width - curindent
                end
            else
                parameter.hangafter = math.max(parameter.hangafter, math.ceil(math.round(i_ht / ht_nodelist, 1)))
            end
        end

        parameter.hangafter = parameter.hangafter * -1
        parameter.disable_hyphenation = tf.disable_hyphenation
        local prepend = prop_prependlist or self_prependlist
        -- Cache alignment check
        local tf_alignment = tf.alignment
        local ragged_shape = (
            tf_alignment == "leftaligned"
            or tf_alignment == "rightaligned"
            or tf_alignment == "centered"
            or tf_alignment == "start"
            or tf_alignment == "end"
        )

        -- if the last items are newline nodes, clear them (see #142)
        local tail = node.slide(nodelist)
        while tail and publisher.attribute_helpers.get_attribute(tail, "newline") do
            nodelist = node.remove(nodelist, tail)
            tail = node.tail(nodelist)
        end

        if nodelist == nil then
            -- ignore
        else
            -- If there is ragged shape (i.e. not a rectangle of text) then we should turn off
            -- font expansion. This is done by setting tex.(pdf)adjustspacing to 0 temporarily
            if ragged_shape then
                local save_tolerance = parameter.tolerance
                local save_hyphenpenalty = parameter.hyphenpenalty
                parameter.tolerance = 5000
                parameter.hyphenpenalty = 200

                local adjspace
                adjspace = tex.adjustspacing
                tex.pdfadjustspacing = 0
                tex.adjustspacing = 0
                nodelist = publisher.nodes.do_linebreak(nodelist, width_sp, parameter)

                parameter.tolerance = save_tolerance
                parameter.hyphenpenalty = save_hyphenpenalty

                tex.pdfadjustspacing = adjspace
                tex.adjustspacing = adjspace
                publisher.nodes.fix_justification(nodelist, tf_alignment, nil, prop_pardir)
            else
                nodelist = publisher.nodes.do_linebreak(nodelist, width_sp, parameter)
            end

            if thispaddingleft > 0 then
                indent_nodelist(nodelist, thispaddingleft)
            end
            if thispaddingright > 0 then
                widen_nodelist(nodelist, thispaddingright)
            end
            for _, v in ipairs(langs) do
                lang.prehyphenchar(v.l, v.prehyphenchar)
            end

            -- Remove glue between the lines
            -- it's always 0 anyway (hopefully!)
            local line = nodelist.head
            while line do
                if line.id == glue_node_id then
                    line.prev.next = line.next
                    if line.next then
                        line.next.prev = line.prev
                    end
                end
                line = line.next
            end

            -- Cache tf fields for orphan/widow control
            local tf_orphan = tf.orphan
            local tf_widow = tf.widow
            line = nodelist.head
            local c = 0
            while line do
                c = c + 1
                if c < tf_orphan and line.next then
                    if line.head and not node.has_attribute(line.head, att_ignore_orphan) then
                        node.set_attribute(line, att_break_below, 1)
                    end
                end
                if publisher.page_helpers.less_or_equal_than_n_lines(line, tf_widow) then
                    if line.head and not node.has_attribute(line.head, att_ignore_orphan) then
                        node.set_attribute(line, att_break_below, 2)
                    end
                end
                line = line.next
            end

            if publisher.options.showtextformat then
                local tfname = tf.name or "text"
                local ln = nodelist.head
                while ln do
                    if ln.id == hlist_node_id and ln.head then
                        ln.head = publisher.annotate_nodelist(ln, tfname)
                    end
                    ln = ln.next
                end
            end

            publisher.fonts.post_linebreak(nodelist)
            if self_margin_top then
                nodelist.list = publisher.nodes.add_glue(
                    nodelist.list,
                    "head",
                    { width = self_margin_top },
                    "par.lua/if self.margin_top"
                )
                publisher.attribute_helpers.set_attribute(nodelist.list, "margintop", 1)
            end
            if self_border_top_width and self_border_top_width > 0 then
                nodelist.list = publisher.nodes.add_glue(
                    nodelist.list,
                    "head",
                    { width = self_border_top_width },
                    "par.lua/if self.border_top_width"
                )
                publisher.attribute_helpers.set_attribute(nodelist.list, "margintop", 1)
            end
            if has_margin_top then
                nodelist.list = publisher.nodes.add_glue(
                    nodelist.list,
                    "head",
                    { width = has_margin_top },
                    "par.lua/if has_margin_top"
                )
                publisher.attribute_helpers.set_attribute(nodelist.list, "margintop", 1)
            end
            if self_padding_top and self_padding_top > 0 then
                nodelist.list = publisher.nodes.add_glue(
                    nodelist.list,
                    "head",
                    { width = self_padding_top },
                    "par.lua/self.padding_top"
                )
                publisher.attribute_helpers.set_attribute(nodelist.list, "margintop", 1)
            end
            if tf.paddingtop and tf.paddingtop ~= 0 then
                nodelist.list = publisher.nodes.add_glue(nodelist.list, "head", { width = tf.paddingtop })
                node.set_attribute(nodelist.list, att_break_below, 3)
            end
            if tf.bordertop and tf.bordertop ~= 0 then
                nodelist.list = publisher.nodes.add_rule(
                    nodelist.list,
                    "head",
                    { width = -1073741824, height = tf.bordertop },
                    "par.lua/tf.bordertop"
                )
                node.set_attribute(nodelist.list, att_break_below, 4)
            end
            if tf.margintop and tf.margintop ~= 0 then
                nodelist.list = publisher.nodes.add_glue(nodelist.list, "head", { width = tf.margintop })
                node.set_attribute(nodelist.list, att_margin_top_boxstart, tf.margintopboxstart)
                node.set_attribute(nodelist.list, att_break_below, 6)
            end
            if tf.breakbelow == false or self.break_after == "avoid" then
                node.set_attribute(node.tail(nodelist.list), att_break_below, 5)
            end

            local break_before = tf.break_before or self.break_before
            if break_before == "page" then
                node.set_attribute(nodelist.list, publisher.att_break_before, 1)
            elseif break_before == "always" then
                node.set_attribute(nodelist.list, publisher.att_break_before, 2)
            end

            if self.padding_bottom and self.padding_bottom > 0 then
                local glue
                nodelist.list, glue = publisher.nodes.add_glue(
                    nodelist.list,
                    "tail",
                    { width = self.padding_bottom },
                    "par.lua/self.padding_bottom"
                )
                publisher.attribute_helpers.set_attribute(glue, "paddingbottom", 1)
            end

            if tf.borderbottom and tf.borderbottom ~= 0 then
                nodelist.list = publisher.nodes.add_rule(
                    nodelist.list,
                    "tail",
                    { width = width_sp, height = tf.borderbottom },
                    "par.lua/tf.borderbottom"
                )
                node.set_attribute(node.tail(nodelist.list), publisher.att_break_below_forbidden, 6)
            end
            if tf.marginbottom and tf.marginbottom ~= 0 then
                nodelist.list = publisher.nodes.add_glue(
                    nodelist.list,
                    "tail",
                    { width = tf.marginbottom },
                    "par.lua/tf.marginbottom"
                )
                node.set_attribute(node.tail(nodelist.list), publisher.att_omit_at_top, 1)
            end
            if self.border_bottom_width and self.border_bottom_width > 0 then
                nodelist.list = publisher.nodes.add_glue(
                    nodelist.list,
                    "tail",
                    { width = self.border_bottom_width },
                    "par.lua/if self.border_bottom_width"
                )
            end
            if self.margin_bottom then
                nodelist.list = publisher.nodes.add_glue(
                    nodelist.list,
                    "tail",
                    { width = self.margin_bottom },
                    "par.lua/self.margin_bottom"
                )
                node.set_attribute(node.tail(nodelist.list), publisher.att_omit_at_top, 1)
            end
            if has_margin_bottom then
                nodelist.list = publisher.nodes.add_glue(nodelist.list, "tail", { width = has_margin_bottom })
                node.set_attribute(node.tail(nodelist.list), publisher.att_omit_at_top, 1)
            end

            node.set_attribute(nodelist.list, publisher.att_margin_newcolumn, tf.colpaddingtop or 0)
            if tf.breakbelow == false or self.break_after == "avoid" then
                node.set_attribute(node.tail(nodelist.list), publisher.att_break_below_forbidden, 7)
            end
            objects[i] = nodelist.list
            if prepend then
                local prependnodelist = nil
                for j = 1, #prepend do
                    local thisprepend = prepend[j]
                    local prependoptions = thisprepend[3] or options
                    local str = thisprepend[1]
                    if prependoptions.color and prependoptions.color ~= 1 then
                        self.has_color = true
                    end
                    local label
                    if type(str) == "string" then
                        label = node.hpack(publisher.nodes.mknodes(str, prependoptions, "par prepend"))
                    elseif node.is_node(str) then
                        label = str
                    end
                    if label then
                        if prependoptions.marker_shift then
                            label.shift = prependoptions.marker_shift
                        end
                        local wd = thisprepend[2] or node.dimensions(label)
                        local labeldistance = thisprepend[4] or tex.sp("5pt") or 0
                        local labelalign = thisprepend[5] or "right"
                        local labelbox =
                            publisher.nodes.whatever_hbox(label, wd, prependoptions, labeldistance, labelalign)
                        if prependnodelist then
                            prependnodelist = node.insert_after(prependnodelist, node.tail(prependnodelist), labelbox)
                        else
                            prependnodelist = labelbox
                        end
                    end
                end
                if prependnodelist then
                    prependnodelist = node.hpack(prependnodelist)
                    prependnodelist.head = publisher.nodes.add_glue(
                        prependnodelist.head,
                        "head",
                        { width = -prependnodelist.width, shrink = 2 ^ 16, shrink_order = 3 }
                    )
                    prependnodelist.width = 0

                    local thisobject = objects[i]
                    while thisobject do
                        if thisobject.id == publisher.hlist_node then
                            break
                        end
                        thisobject = thisobject.next
                    end
                    local cur = thisobject.head
                    while cur do
                        if cur.id ~= publisher.glue_node then
                            cur = node.insert_before(thisobject.head, cur, prependnodelist)
                            break
                        end
                        cur = cur.next
                    end
                    thisobject.head = cur
                end
            end

            nodelist.list = nil
            node.free(nodelist)
        end
    end

    for i = 1, #objects - 1 do
        local last = node.tail(objects[i])
        last.next = objects[i + 1]
        objects[i + 1].prev = last
    end

    local nodelist = node.vpack(objects[1])

    if self.startendborder or self.startborder then
        local wd, ht, margintop = get_border_width_height_margintop(nodelist)
        local bordernumber = self.startendborder or self.startborder
        local ba = publisher.borderattributes[bordernumber]
        ba.shiftdown = margintop

        publisher.attribute_helpers.set_attribute(nodelist.list, "bordernumber", bordernumber)
        publisher.attribute_helpers.set_attribute(nodelist.list, "borderwd", wd)
        publisher.attribute_helpers.set_attribute(nodelist.list, "borderht", ht)
    end
    publisher.attribute_helpers.setprop(nodelist, "origin", "par:format")
    -- Transfer feature flags from Par to the vlist so insert_nonmoving_whatsits can skip clean paragraphs
    if self.has_color then
        publisher.attribute_helpers.setprop(nodelist, "has_color", true)
    end
    if self.has_hyperlink then
        publisher.attribute_helpers.setprop(nodelist, "has_hyperlink", true)
    end
    if self.has_role then
        publisher.attribute_helpers.setprop(nodelist, "has_role", true)
    end
    if self.has_special_nodes then
        publisher.attribute_helpers.setprop(nodelist, "has_special_nodes", true)
    end
    -- Transfer PDF/UA structure properties to the vlist
    if publisher.options.format == "PDF/UA" and self.role then
        publisher.attribute_helpers.setprop(nodelist, "role", self.role)
        publisher.attribute_helpers.setprop(nodelist, "parentid", self.parent or "doc")
        publisher.attribute_helpers.setprop(nodelist, "rolecounter", self.rolecounter)
        publisher.attribute_helpers.setprop(nodelist, "id", self.id)
        publisher.attribute_helpers.setprop(nodelist, "structpos", self.structpos)
        publisher.attribute_helpers.setprop(nodelist, "actualtext", self.actualtext)
        node.set_attribute(nodelist, publisher.att_role, self.role)
    end

    if self.initial then
        local ht_nodelist = get_lineheight(nodelist)
        local initial_hlist = self.initial

        -- Node lists of width 0 stick to the right, which is
        -- good for rtl text, but not for ltr. So on non-rtl text
        -- the shift left must be equal to the width
        if self.direction ~= "rtl" then
            initial_hlist.shift = -initial_hlist.width
        end
        publisher.attribute_helpers.setprop(self.initial, "origin", "initial")
        initial_hlist = node.vpack(initial_hlist)
        publisher.attribute_helpers.setprop(initial_hlist, "origin", "initial")

        local shift_down = ht_nodelist * 0.75 - initial_hlist.height
        initial_hlist.shift = -shift_down
        initial_hlist.width = 0
        nodelist.head.head = node.insert_before(nodelist.head.head, nodelist.head.head, initial_hlist)
    end
    self.objects = nil
    self.nodelist = nodelist
    -- Mark vbox if it should be kept with the next block (for break-after: avoid)
    if self.break_after == "avoid" then
        publisher.attribute_helpers.setprop(nodelist, "keep_with_next", true)
    end
    return nodelist
end

-- Appends an item (or list of items) to the par.
---@param self Par
---@param whatever any|any[]
---@param options? table Style overrides applied to the appended items.
---@return nil
function Par:append(whatever, options)
    options = options or {}
    if options.initial and not self.initial then
        self.initial = options.initial
    end
    self.direction = self.direction or options.direction
    if options.textformat and not self.textformat then
        self.textformat = options.textformat
    end
    if options.padding_right and not self.padding_right then
        self.padding_right = options.padding_right
    end
    if options.labelleft then
        self:prepend({
            options.labelleft,
            options.labelleftwidth,
            options,
            options.labelleftdistance,
            options.labelleftalign,
        })
    end
    -- w("whatever %s type %s",tostring(whatever), type(whatever))
    if type(whatever) == "string" then
        whatever = { whatever }
    end
    table.insert(self, { contents = whatever, options = options })
end

file_end("par.lua")

return Par
