diff --git a/Dockerfile b/Dockerfile index 5a046c4..b8e0ade 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM node@sha256:0557ac14e0d45d02ed563067b82856ca5e7aa3437fa28d98d4350ea9c3d9494 RUN git clone \ https://github.com/markup-carve/pandoc-carve.git /opt/pandoc-carve \ && cd /opt/pandoc-carve \ - && git checkout 60e219aa84db977f07e7c2d0b360512ae36e46c9 \ + && git checkout af285cc8edae3bcb1fe1abcfdcde221c5bbe3f1b \ && git submodule update --init --recursive \ && npm ci \ && npm run build \ diff --git a/README.md b/README.md index 2d925fd..e158555 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ not required. - SILE 0.15.13 or later - resilient.sile 4.2.0 or later -- `pandoc-carve` on `PATH` +- `pandoc-carve` on `PATH`, built from a checkout that maps `figure_group` + (pandoc-carve `af285cc` or later, which is what `Dockerfile` pins) `pandoc-carve` is not yet published in the npm registry. For now, install it from its GitHub checkout: @@ -59,8 +60,12 @@ inputter has been loaded. ./test/smoke.sh ``` -The test checks conversion whenever `pandoc-carve` is installed and additionally -checks PDF generation when SILE is available. +The test runs the `carve.figuregroup` unit test whenever a Lua interpreter is +available, checks conversion whenever `pandoc-carve` is installed, and +additionally typesets `examples/smoke.crv`, `examples/composite-figure.crv` and +`examples/composite-figure-nested.crv` when SILE is available. For the two +composite examples it reads back the list entries SILE wrote and checks that +the group is one unit and that group content which is not a panel is not. For a reproducible end-to-end test using SILE's official container image: @@ -81,52 +86,71 @@ decided entirely by the two layers underneath it: the Carve engine pandoc-carve depends on, and pandoc-carve's mapping to the Pandoc AST. A construct either layer does not know about cannot be recovered here. -### Composite figures are not grouped floats yet +### Composite figures number as one unit A bare `::: figure` container is one figure of ordered panels under a single -caption (Carve PART 9 section 4c). This pipeline does not typeset it as one -float today, and the reason is worth stating precisely, because two separate -layers have to move first. +caption (Carve PART 9 section 4c). The group is one numbering unit and only the +group produces a list-of-figures entry; its panels take neither a number nor an +entry, and a number placeholder in a panel caption stays literal. -Input: +Input, `examples/composite-figure.crv` in short: -``` -{#fig-x .columns-2} +```` +{#fig-mixed .columns-2} ::: figure -{#fig-x-a} -![one](a.png) -^ (a) One - -{#fig-x-b} -![two](b.png) -^ (b) Two -::: -^ Figure #: Group caption +| Kind | N | +|------|---| +| a | 1 | +^ (a) A table panel -See and . +``` js +const x = 1 ``` +^ (b) A listing panel +::: +^ Figure #: Two panels, one figure +```` + +pandoc-carve maps that to a Pandoc `Figure` whose direct `Figure` and `Table` +children are the panels. Resilient's `pandocast` renderer turns each of those +into its own captioned float, so left alone a two-panel group consumes three +figure numbers, files three list-of-figures entries, and ends up numbered +`Figure 3` while the caption Carve resolved still reads `Figure 1:`. + +`carve/figuregroup.lua` closes that gap. It walks the parsed tree, and on every +captioned figure it marks the direct captioned children `unnumbered` and +`notoc`, which are classes Resilient's `markdown:internal:captioned-figure` and +`markdown:internal:captioned-table` commands already read. The group is then +the only numbered element, its number agrees with the one Carve wrote into the +caption, and the list of figures has one entry per group. + +Only direct children are panels. A captioned figure inside group content, in +a `::: note` or in the generic div a nested bare `::: figure` degrades to, +keeps its own number, which is what corpus documents `318-composite-figures-9` +and `-11` pin. An opener carrying a quoted title or a `[label]` is not this +production at all: it stays a generic container, and what it holds numbers on +its own. + +#### What is still missing + +The group numbers as one unit, but it is not yet laid out as one. + +- Resilient's captioned elements are, in its own words, not floats. There is no + float mechanism to place a group into, so a composite figure sits in the text + flow where it was written. +- The `columns-N` layout hint arrives as a class on the group and nothing below + acts on it. Two panels stack vertically rather than sitting side by side. + Turning that into a real multi-column arrangement needs a renderer, which is + the one thing this adapter deliberately does not own. +- Carve resolves the caption placeholder before the text reaches SILE, and + Resilient prepends its own `Figure N.` to every caption. A caption written + `^ Figure #: ...` therefore renders its label twice. This is not specific to + composite figures; a single captioned image does the same. +- The two counters only agree in a document where every captioned figure + carries a placeholder. Resilient numbers every captioned figure it typesets; + Carve numbers only the captions that carry one. A caption written without a + placeholder still consumes a Resilient number, and everything after it is + numbered one higher than Carve thinks. `examples/composite-figure-nested.crv` + is deliberately such a document, which is why its check asserts entries + rather than numbers. -What reaches SILE today, with the published engine: - -- the container is an ordinary `Div` carrying the `admonition`, `figure` and - `columns-2` classes, holding the two panels as separate Pandoc figures; -- the group caption is a PARAGRAPH whose text is the literal `^ Figure #: Group - caption`, caret and placeholder included, because a caption after a container - closer is section 4c's rule and the published engine predates it; -- both cross-references degrade to their bare target text, since nothing - numbered the group. - -The order of the gate: - -1. an `@markup-carve/carve` release containing the `figure_group` node - it is - implemented in carve-js but is not in 0.1.3, the newest published version and - the one pandoc-carve resolves; -2. pandoc-carve mapping `figure_group` to a Pandoc figure containing the panel - figures, so the group caption and the panel captions arrive as captions - rather than as text; -3. Resilient's `pandocast` renderer placing that as a float, at which point the - `columns-N` hint has something to act on. - -Nothing in this repository sits between those steps, so there is no adapter-side -workaround: code here that recognized a grouped figure would be matching a shape -no layer below it emits. diff --git a/carve-sile-dev-1.rockspec b/carve-sile-dev-1.rockspec index b290b0a..35e9b59 100644 --- a/carve-sile-dev-1.rockspec +++ b/carve-sile-dev-1.rockspec @@ -26,5 +26,6 @@ build = { modules = { ["sile.inputters.carve"] = "inputters/carve.lua", ["sile.carve.bridge"] = "carve/bridge.lua", + ["sile.carve.figuregroup"] = "carve/figuregroup.lua", }, } diff --git a/carve/figuregroup.lua b/carve/figuregroup.lua new file mode 100644 index 0000000..a6b08e0 --- /dev/null +++ b/carve/figuregroup.lua @@ -0,0 +1,88 @@ +--- Make a Carve composite figure number as one unit in the Resilient renderer. +-- +-- Carve PART 9 section 4c: a bare `::: figure` container is ONE figure of +-- ordered panels. Its direct `figure` and `table` children are the panels; the +-- group is one numbering unit, and only the group produces a list-of-figures +-- entry. A number placeholder in a panel caption stays literal. +-- +-- pandoc-carve maps that node to a Pandoc `Figure` whose direct `Figure` and +-- `Table` children are the panels, and Resilient's `pandocast` renderer turns +-- every one of those into its own captioned float. Left alone, a group of two +-- panels therefore consumes three figure numbers and files three list-of- +-- figures entries, and Resilient's counter stops agreeing with the number +-- Carve already resolved into the group caption. +-- +-- Resilient's `markdown:internal:captioned-*` commands read the `unnumbered` +-- and `notoc` classes off their options, so the whole correction is to put +-- those two classes on the panels. Nothing else about the group changes: the +-- panels keep their captions, their ids and their position, and group content +-- that is not a panel is left exactly where it was. +-- +-- Only DIRECT children are panels. A captioned figure that sits inside group +-- content -- inside a `::: note`, or inside the generic div a nested bare +-- `::: figure` degrades to -- is not a panel and keeps its own number, which +-- is what corpus documents 318-composite-figures-9 and -11 pin. + +local figuregroup = {} + +-- The two commands `pandocast` produces for a captioned float. It has no path +-- to `markdown:internal:captioned-listing`, so that one is deliberately absent +-- rather than listed for symmetry: a panel can only arrive as one of these. +local CAPTIONED = { + ["markdown:internal:captioned-figure"] = true, + ["markdown:internal:captioned-table"] = true, +} + +local PANEL_CLASSES = { "unnumbered", "notoc" } + +local function hasClass (classes, name) + return string.find(" " .. classes .. " ", " " .. name .. " ", 1, true) ~= nil +end + +local function markPanel (node) + node.options = node.options or {} + local classes = node.options.class or "" + for _, name in ipairs(PANEL_CLASSES) do + if not hasClass(classes, name) then + classes = classes == "" and name or (classes .. " " .. name) + end + end + node.options.class = classes +end + +-- A captioned figure holds { , }. The first slot is +-- the list of the group's direct children, except that pandocast collapses a +-- one-element list to the element itself, so a single-panel group arrives as a +-- bare command node. +local function directChildren (node) + local slot = node[1] + if type(slot) ~= "table" then + return {} + end + if slot.command then + return { slot } + end + return slot +end + +--- Mark the panels of every composite figure in a SILE AST, in place. +-- @tparam table tree SILE AST node, or a list of them +-- @treturn table the same tree +function figuregroup.mark (tree) + if type(tree) ~= "table" then + return tree + end + if tree.command == "markdown:internal:captioned-figure" then + for _, child in ipairs(directChildren(tree)) do + if type(child) == "table" and CAPTIONED[child.command] then + markPanel(child) + end + end + end + for _, child in ipairs(tree) do + figuregroup.mark(child) + end + return tree +end + +return figuregroup diff --git a/examples/composite-figure-nested.crv b/examples/composite-figure-nested.crv new file mode 100644 index 0000000..ba20f67 --- /dev/null +++ b/examples/composite-figure-nested.crv @@ -0,0 +1,20 @@ +--- +title: Composite figures, nested content +author: Carve contributors +--- + +# Nested content is not a panel + +Only the direct `figure` and `table` children of a bare `::: figure` container +are its panels (Carve PART 9 section 4c). A captioned figure that sits inside +group content is not a panel and keeps a number of its own. + +:::: figure +::: note +``` js +const z = 3 +``` +^ A listing inside group content, still its own figure +::: +:::: +^ Figure #: A group whose content is not a panel diff --git a/examples/composite-figure.crv b/examples/composite-figure.crv new file mode 100644 index 0000000..8d3612c --- /dev/null +++ b/examples/composite-figure.crv @@ -0,0 +1,34 @@ +--- +title: Composite figures +author: Carve contributors +--- + +# Composite figures + +A bare `::: figure` opener is one figure of ordered panels (Carve PART 9 +section 4c). The group takes one figure number and files one list-of-figures +entry; the panels take neither. + +{#fig-mixed .columns-2} +::: figure +| Kind | N | +|------|---| +| a | 1 | +^ (a) A table panel + +``` js +const x = 1 +``` +^ (b) A listing panel +::: +^ Figure #: Two panels, one figure + +An opener that carries a quoted title is not that production. It stays a +generic container, and what it holds keeps numbering on its own. + +::: figure "A titled container, not a group" +``` js +const y = 2 +``` +^ A listing that is still its own figure +::: diff --git a/inputters/carve.lua b/inputters/carve.lua index a277b58..275aa12 100644 --- a/inputters/carve.lua +++ b/inputters/carve.lua @@ -2,6 +2,7 @@ local base = require("inputters.base") local bridge = require("carve.bridge") +local figuregroup = require("carve.figuregroup") local pandocast = require("inputters.pandocast") local inputter = pl.class(base) @@ -25,7 +26,11 @@ function inputter:parse (doc) -- Reuse resilient.sile's mature Pandoc AST-to-SILE renderer. This returns -- the complete document AST, including the default markdown/resilient class. - return pandocast(self.options):parse(json) + local tree = pandocast(self.options):parse(json) + + -- A Carve composite figure is one numbering unit (PART 9 section 4c), but + -- the renderer above numbers each panel separately. Suppress the panels. + return figuregroup.mark(tree) end return inputter diff --git a/test/figuregroup.lua b/test/figuregroup.lua new file mode 100644 index 0000000..0db001e --- /dev/null +++ b/test/figuregroup.lua @@ -0,0 +1,113 @@ +-- Unit test for carve/figuregroup.lua. Pure Lua: no SILE, no Resilient. +-- +-- The fixtures below are the shape Resilient's pandocast renderer actually +-- produces for examples/composite-figure.crv, read off the parsed tree: +-- +-- markdown:internal:captioned-figure class=columns-2 +-- +-- markdown:internal:captioned-table +-- markdown:internal:captioned-figure +-- caption +-- markdown:internal:div class=admonition figure +-- +-- markdown:internal:paragraph +-- markdown:internal:captioned-figure +-- +-- test/smoke.sh checks the same thing end to end through SILE, against the +-- list-of-figures entries the run writes out. This one is the fast half. + +package.path = (arg[0]:match("^(.*)/test/[^/]+$") or ".") .. "/?.lua;" .. package.path +local figuregroup = require("carve.figuregroup") + +local failures = 0 + +local function check (name, condition) + if condition then + print("ok - " .. name) + else + print("FAIL - " .. name) + failures = failures + 1 + end +end + +local function command (name, options, children) + local node = children or {} + node.command = name + node.id = "command" + node.options = options or {} + return node +end + +local function classOf (node) + return node.options and node.options.class or "" +end + +-- A group: two panels, one of each kind pandocast can produce. +local tablePanel = command("markdown:internal:captioned-table", {}) +local figurePanel = command("markdown:internal:captioned-figure", {}) +local group = command("markdown:internal:captioned-figure", { class = "columns-2" }, { + { tablePanel, figurePanel }, + command("caption", {}), +}) + +-- The control: the same panel content under a titled opener, which is not a +-- composite figure but a generic container. +local containedFigure = command("markdown:internal:captioned-figure", { class = "" }) +local container = command("markdown:internal:div", { class = "admonition figure" }, { + { command("markdown:internal:paragraph", {}), containedFigure }, +}) + +-- Group content that is not a panel, and a panel that already carries a class. +local prose = command("markdown:internal:paragraph", {}) +local keptPanel = command("markdown:internal:captioned-figure", { class = "custom" }) +local mixedGroup = command("markdown:internal:captioned-figure", {}, { + { prose, keptPanel }, + command("caption", {}), +}) + +-- A single-panel group: pandocast collapses a one-element block list to the +-- element itself, so slot 1 is the panel node rather than a list. +local lonePanel = command("markdown:internal:captioned-figure", {}) +local loneGroup = command("markdown:internal:captioned-figure", {}, { + lonePanel, + command("caption", {}), +}) + +-- Group content that is itself a container holding a captioned figure. That +-- figure is not a direct child of the group, so it is not a panel: corpus +-- documents 318-composite-figures-9 and -11 give it a number of its own. +local nestedFigure = command("markdown:internal:captioned-figure", { class = "" }) +local noteInGroup = command("markdown:internal:div", { class = "admonition note" }, { + nestedFigure, +}) +local nestedGroup = command("markdown:internal:captioned-figure", {}, { + noteInGroup, + command("caption", {}), +}) + +figuregroup.mark({ group, container, mixedGroup, loneGroup, nestedGroup }) + +check("a table panel is unnumbered", classOf(tablePanel):match("unnumbered") ~= nil) +check("a table panel is out of the list of figures", classOf(tablePanel):match("notoc") ~= nil) +check("a figure panel is unnumbered", classOf(figurePanel):match("unnumbered") ~= nil) +check("a figure panel is out of the list of figures", classOf(figurePanel):match("notoc") ~= nil) +check("the group keeps its own number", classOf(group) == "columns-2") +check("the group keeps its layout hint", classOf(group):match("columns%-2") ~= nil) + +check("a figure in a titled container still numbers", classOf(containedFigure) == "") +check("a figure inside group content is not a panel", classOf(nestedFigure) == "") + +check("non-panel group content is untouched", classOf(prose) == "") +check("a panel keeps the classes it had", classOf(keptPanel) == "custom unnumbered notoc") + +check("a lone panel is unnumbered", classOf(lonePanel) == "unnumbered notoc") + +-- Marking twice must not double the classes. +figuregroup.mark({ group }) +check("marking is idempotent", classOf(figurePanel) == "unnumbered notoc") + +if failures > 0 then + print(failures .. " failure(s)") + os.exit(1) +end +print("PASS: carve.figuregroup") diff --git a/test/smoke.sh b/test/smoke.sh index 18354b1..ed3a3b5 100755 --- a/test/smoke.sh +++ b/test/smoke.sh @@ -3,6 +3,20 @@ set -eu repo_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +lua_bin="" +for candidate in lua lua5.4 lua5.3 lua5.1 luajit; do + if command -v "$candidate" >/dev/null 2>&1; then + lua_bin=$candidate + break + fi +done + +if [ -n "$lua_bin" ]; then + (cd "$repo_dir" && "$lua_bin" test/figuregroup.lua) +else + echo "SKIP: no Lua interpreter for the carve.figuregroup unit test" +fi + if ! command -v pandoc-carve >/dev/null 2>&1; then echo "SKIP: pandoc-carve is not installed" exit 0 @@ -12,6 +26,15 @@ json=$(pandoc-carve "$repo_dir/examples/smoke.crv" -t json) printf '%s' "$json" | grep -q '"pandoc-api-version"' printf '%s' "$json" | grep -q '"t":"Header"' +# The converter has to map a bare `::: figure` opener to a Pandoc Figure whose +# panels are nested Figures and Tables, and a titled opener to a plain Div. +# Everything this repository does with composite figures rests on that split, +# so check it rather than assume the installed converter is recent enough. +composite=$(pandoc-carve "$repo_dir/examples/composite-figure.crv" -t json) +printf '%s' "$composite" \ + | grep -q '{"t":"Figure","c":\[\["fig-mixed",\["columns-2"\],\[\]\]' +printf '%s' "$composite" | grep -q '"t":"Div","c":\[\["",\["admonition","figure"\]' + if ! command -v sile >/dev/null 2>&1; then echo "PASS: Carve to Pandoc JSON (SILE is not installed; PDF check skipped)" exit 0 @@ -23,3 +46,17 @@ cd "$work_dir" sile -o "$work_dir/smoke.pdf" -u inputters.carve "$repo_dir/examples/smoke.crv" test -s smoke.pdf echo "PASS: examples/smoke.crv -> smoke.pdf" + +for example in composite-figure:panels composite-figure-nested:nested; do + name=${example%:*} + mode=${example#*:} + cp "$repo_dir/examples/$name.crv" "$work_dir/$name.crv" + sile -o "$work_dir/$name.pdf" -u inputters.carve "$work_dir/$name.crv" + test -s "$work_dir/$name.pdf" + if [ -z "$lua_bin" ]; then + echo "SKIP: no Lua interpreter to read the list entries of $name" + continue + fi + "$lua_bin" "$repo_dir/test/toccheck.lua" "$mode" "$work_dir/$name.toc" + echo "PASS: examples/$name.crv -> $name.pdf" +done diff --git a/test/toccheck.lua b/test/toccheck.lua new file mode 100644 index 0000000..e4c4abf --- /dev/null +++ b/test/toccheck.lua @@ -0,0 +1,116 @@ +-- Check the list entries a SILE run produced for a composite-figure example. +-- +-- toccheck.lua panels examples/composite-figure.crv +-- toccheck.lua nested examples/composite-figure-nested.crv +-- +-- Resilient writes its table-of-contents data as a Lua chunk next to the +-- master file, one record per entry, with figure captions at level 5 and table +-- captions at level 6. That makes the numbering observable without reading the +-- PDF. + +local mode = assert(arg[1], "usage: toccheck.lua ") +local path = assert(arg[2], "usage: toccheck.lua ") +local toc = assert(loadfile(path))() + +local failures = 0 + +local function check (name, condition, detail) + if condition then + print("ok - " .. name) + else + print("FAIL - " .. name .. (detail and (" (" .. detail .. ")") or "")) + failures = failures + 1 + end +end + +local function flatten (node, out) + if type(node) == "string" then + out[#out + 1] = node + elseif type(node) == "table" then + for _, child in ipairs(node) do + flatten(child, out) + end + end + return out +end + +local figures = {} +local tables = {} +for _, entry in ipairs(toc) do + local record = { + number = entry.number, + text = table.concat(flatten(entry.label, {}), " "), + } + if entry.level == 5 then + figures[#figures + 1] = record + elseif entry.level == 6 then + tables[#tables + 1] = record + end +end + +local function has (list, needle) + for _, record in ipairs(list) do + if record.text:find(needle, 1, true) then + return record + end + end + return nil +end + +if mode == "panels" then + -- Section 4c wants exactly two entries here: the composite figure, one unit + -- whatever its panel count, and the captioned listing in the titled + -- container, which is not a group and numbers on its own. Without + -- carve/figuregroup.lua the listing panel files an entry of its own, the + -- count is three, and Resilient numbers the group 2 while the caption Carve + -- resolved still reads "Figure 1:". + check("the example files two list-of-figures entries", #figures == 2, + "found " .. #figures) + + -- A table panel goes to the list of tables rather than the list of figures, + -- so it needs suppressing on its own account. Without that the example files + -- one entry here, and the panel takes table number 1. + check("the table panel files no list-of-tables entry", #tables == 0, + "found " .. #tables) + + if #figures == 2 then + check("the composite figure is the first entry", + figures[1].text:find("Two panels, one figure", 1, true) ~= nil, + figures[1].text) + check("Resilient numbers the group 1", figures[1].number == "1", + tostring(figures[1].number)) + check("the caption Carve resolved agrees with that number", + figures[1].text:find("Figure 1:", 1, true) ~= nil, + figures[1].text) + check("the titled container's listing numbers on its own", + figures[2].number == "2" and + figures[2].text:find("still its own figure", 1, true) ~= nil, + tostring(figures[2].number) .. " " .. figures[2].text) + end +elseif mode == "nested" then + -- The captioned listing here sits inside a `::: note` inside the group. It + -- is group content, not a panel, so it keeps a number and an entry of its + -- own; only the group's DIRECT figure and table children are panels. A rule + -- written over descendants instead of direct children suppresses it and + -- leaves one entry. + -- + -- No number is asserted. Resilient numbers every captioned figure, while + -- Carve only numbers the captions that carry a placeholder, so the two + -- counters agree only in a document where every caption carries one. This + -- example deliberately is not such a document. + check("the example files two list-of-figures entries", #figures == 2, + "found " .. #figures) + check("the group files an entry", + has(figures, "A group whose content is not a panel") ~= nil) + check("group content that is not a panel keeps its own entry", + has(figures, "still its own figure") ~= nil) +else + print("unknown mode: " .. mode) + os.exit(1) +end + +if failures > 0 then + print(failures .. " failure(s)") + os.exit(1) +end +print("PASS: composite figure numbering (" .. mode .. ")")