From c8c567f5343018b003b01d30a8e323d1e53702d6 Mon Sep 17 00:00:00 2001 From: yrstm <12587046+yrstm@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:10:16 +0100 Subject: [PATCH 1/2] Render content images at their original position in the prose flow Images captured from a live DOM now carry a `position` anchor (the index of the block they follow, or -1 to lead the document), mirroring how data tables are anchored. toMarkdown splices positioned images into the flow, interleaved with prose and tables, instead of listing every image at the document tail. Images without a position (vision pipeline, stored articles, out-of-range anchors) keep the trailing-list fallback, and a new `unpositionedImages` diagnostic mirrors `unpositionedTables`. Spliced images keep the lowest render priority, so the "outline" budget still sheds images first and never spends heading/lead budget on them. Co-Authored-By: Claude Fable 5 --- README.md | 6 ++- docs/index.html | 5 ++ mantis.d.ts | 9 ++++ mantis.js | 120 +++++++++++++++++++++++++++++++++--------------- test.js | 66 ++++++++++++++++++++++++++ 5 files changed, 167 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index c525537..a2b46a6 100644 --- a/README.md +++ b/README.md @@ -168,8 +168,10 @@ const markdown = Mantis.toMarkdown(article, { frontmatter: true, budget: "outlin | `includeTables` | `true` | Hard caps: 200 links, 100 images, 50 tables. Non-content images such as avatars, icons, logos, -badges, social buttons, and tracking pixels are filtered before Markdown rendering. `selection` is -only captured in a live browser context; it is always `null` in `fromHTML()`. +badges, social buttons, and tracking pixels are filtered before Markdown rendering. Content images +and data tables render at their original position in the prose flow; items without a captured +position (vision-pipeline or stored articles) are appended at the end. `selection` is only captured +in a live browser context; it is always `null` in `fromHTML()`. Frontmatter also includes cheap routing signals when available: `captureMode`, `imageCount`, `selectionChars`, `blockCount`, `citationCount`, `linkCount`, and `tableCount`. With frontmatter diff --git a/docs/index.html b/docs/index.html index 959a161..24c94dd 100644 --- a/docs/index.html +++ b/docs/index.html @@ -415,6 +415,11 @@

Screenshot API

Changelog

+
+ +

Images render at their original position

+

Markdown output now places content images where they appeared on the page, interleaved with prose and tables, instead of listing them at the end of the document. Captures without live DOM positions (vision pipeline, stored articles) keep the trailing list.

+

macOS capture repo split

diff --git a/mantis.d.ts b/mantis.d.ts index 331039e..25ea879 100644 --- a/mantis.d.ts +++ b/mantis.d.ts @@ -17,6 +17,13 @@ export interface MantisImage { alt: string; title: string; source: MantisSource; + /** + * Index of the block this image follows in the document flow (-1 to lead the + * document). Set for images captured from a live DOM so toMarkdown can render + * them at their original position. Absent for vision-pipeline or stored + * articles, which render images in a list at the end instead. + */ + position?: number; } export interface MantisTable { @@ -98,6 +105,8 @@ export interface MantisDiagnostics { fallbackScopeUsed?: boolean; /** Tables not spliced into the flow (layout/nested) and appended at the tail. */ unpositionedTables?: number; + /** Images not spliced into the flow and appended at the tail. */ + unpositionedImages?: number; } export interface MantisExtractOptions { diff --git a/mantis.js b/mantis.js index 71626bf..21e13ec 100644 --- a/mantis.js +++ b/mantis.js @@ -597,7 +597,8 @@ src: src, alt: attr(el, "alt"), title: attr(el, "title"), - source: { selector: selectorFor(el) } + source: { selector: selectorFor(el) }, + __el: el }); } return out; @@ -653,24 +654,39 @@ // tail. Tables that are not plain data, or whose position falls beyond the // captured blocks (e.g. truncated by maxBlocks), get no position and fall back // to being appended at the end, preserving previous behavior and data. + // Index of the last captured block that precedes `el` in document order, or + // -1 when `el` leads the document. + function anchorIndex(blocks, el) { + var anchor = -1; + for (var b = 0; b < blocks.length; b++) { + var bel = blocks[b].__el; + if (!bel) continue; + var rel = bel.compareDocumentPosition(el); + // FOLLOWING (4): el comes after this block; CONTAINS (8): the block is + // inside el (skip those, they are not real preceding blocks). + if ((rel & 4) && !(rel & 8)) anchor = b; + } + return anchor; + } + function positionTables(blocks, tables, scope) { for (var t = 0; t < (tables ? tables.length : 0); t++) { var el = tables[t].__el; - if (el && isDataTableEl(el, scope)) { - var anchor = -1; - for (var b = 0; b < blocks.length; b++) { - var bel = blocks[b].__el; - if (!bel) continue; - var rel = bel.compareDocumentPosition(el); - // FOLLOWING (4): table comes after this block; CONTAINS (8): block is - // inside the table (skip those, they are not real preceding blocks). - if ((rel & 4) && !(rel & 8)) anchor = b; - } - tables[t].position = anchor; - } + if (el && isDataTableEl(el, scope)) tables[t].position = anchorIndex(blocks, el); delete tables[t].__el; } - for (var k = 0; k < blocks.length; k++) delete blocks[k].__el; + } + + // Record where each content image sits relative to the captured blocks so + // toMarkdown can render it at its place in the prose instead of in a list at + // the document tail. Images without a DOM element (vision pipeline, stored + // articles) get no position and keep the trailing-list fallback. + function positionImages(blocks, images) { + for (var m = 0; m < (images ? images.length : 0); m++) { + var el = images[m].__el; + if (el) images[m].position = anchorIndex(blocks, el); + delete images[m].__el; + } } function meta(doc, name) { @@ -828,16 +844,22 @@ droppedBlockCount: blockStats.droppedBlocks || 0, maxTablesHit: !!tableStats.maxTablesHit, fallbackScopeUsed: fallbackScope, - unpositionedTables: 0 + unpositionedTables: 0, + unpositionedImages: 0 } }; - // anchor data tables to their position in the block flow; strips the - // transient DOM references off blocks and tables before any serialization + // anchor data tables and content images to their position in the block + // flow; strips the transient DOM references before any serialization positionTables(article.blocks, article.tables, scope || doc.body); - // tables that were not spliced into the flow (layout/nested) are appended + positionImages(article.blocks, article.images); + for (var kb = 0; kb < article.blocks.length; kb++) delete article.blocks[kb].__el; + // tables and images that were not spliced into the flow are appended for (var ut = 0; ut < article.tables.length; ut++) { if (typeof article.tables[ut].position !== "number") article.diagnostics.unpositionedTables++; } + for (var ui = 0; ui < article.images.length; ui++) { + if (typeof article.images[ui].position !== "number") article.diagnostics.unpositionedImages++; + } article.textHash = hashString(article.text); article.contentHash = hashString(JSON.stringify({ title: article.title, @@ -998,11 +1020,11 @@ if (!blocks.length && article.paragraphs) { blocks = article.paragraphs.map(function (text) { return { type: "paragraph", text: text }; }); } - // Data tables carry a `position` (set during extraction): the index of the - // block they follow, or -1 to lead the document. They are spliced into the - // flow below. Tables without a position (layout/nested tables, vision-pipeline - // tables, or tables truncated past maxBlocks) are appended at the end, which - // preserves prior behavior and never drops data. + // Data tables and content images carry a `position` (set during + // extraction): the index of the block they follow, or -1 to lead the + // document. They are spliced into the flow below. Items without a position + // (layout/nested tables, vision-pipeline captures, stored articles) are + // appended at the end, which preserves prior behavior and never drops data. var renderTables = options.tables !== false; var allTables = article.tables || []; var tablesAt = {}; @@ -1017,16 +1039,41 @@ else (tablesAt[pos] = tablesAt[pos] || []).push(ti); } } + var renderImages = images === "alt" || images === "links"; + var allImages = renderImages ? (article.images || []) : []; + var imagesAt = {}; + var preImages = []; + var splicedImage = []; + for (var mi = 0; mi < allImages.length; mi++) { + var ipos = allImages[mi].position; + // out-of-range anchors (hand-edited or truncated articles) fall back + if (typeof ipos !== "number" || ipos >= blocks.length) continue; + splicedImage[mi] = true; + if (ipos < 0) preImages.push(mi); + else (imagesAt[ipos] = imagesAt[ipos] || []).push(mi); + } + function imageMarkdown(img) { + var alt = escapeInline(img.alt || "image"); + var dest = linkDestination(img.src); + return images === "alt" ? "![" + alt + "](" + dest + ")" : "[" + alt + "](" + dest + ")"; + } var lead = true; // the document lead counts as a section lead // a table directly under a heading is that section's lead content var flushedUpTo = -1; - function flushTablesThrough(idx) { + function flushInlineThrough(idx) { for (var a = flushedUpTo + 1; a <= idx; a++) { var here = tablesAt[a]; - if (!here) continue; - for (var h = 0; h < here.length; h++) { - add(tableMarkdown(allTables[here[h]]), lead ? 2 : 3); - lead = false; + if (here) { + for (var h = 0; h < here.length; h++) { + add(tableMarkdown(allTables[here[h]]), lead ? 2 : 3); + lead = false; + } + } + // images render at their block anchor but keep image priority, so the + // outline budget still sheds them first; they do not consume `lead` + var ihere = imagesAt[a]; + if (ihere) { + for (var g = 0; g < ihere.length; g++) add(imageMarkdown(allImages[ihere[g]]), 4); } } flushedUpTo = idx; @@ -1035,18 +1082,19 @@ add(tableMarkdown(allTables[preTables[pt]]), 2); lead = false; } + for (var pi = 0; pi < preImages.length; pi++) add(imageMarkdown(allImages[preImages[pi]]), 4); for (var i = 0; i < blocks.length; i++) { var b = blocks[i]; // the page H1 usually repeats the title; emit it once var dupH1 = i === 0 && article.title && b.type === "heading" && b.level === 1 && b.text === article.title; if (dupH1) { - flushTablesThrough(i); + flushInlineThrough(i); continue; } if (b.type === "heading") { add(HASHES[Math.min(Math.max(b.level || 1, 1), 6) - 1] + " " + inlineMarkdown(b), 1); lead = true; - flushTablesThrough(i); + flushInlineThrough(i); continue; } var prio = lead ? 2 : 3; @@ -1065,7 +1113,7 @@ } else { add(escapeLeader(inlineMarkdown(b)), prio); } - flushTablesThrough(i); + flushInlineThrough(i); } if (renderTables) { // fallback: tables with no inline position go at the end (as before) @@ -1073,13 +1121,11 @@ if (!splicedTable[tf]) add(tableMarkdown(allTables[tf]), 3); } } - if (images === "alt" || images === "links") { - var imgs = article.images || []; + if (renderImages) { + // fallback: images with no inline position go at the end (as before) var rendered = []; - for (var m = 0; m < imgs.length; m++) { - var alt = escapeInline(imgs[m].alt || "image"); - var dest = linkDestination(imgs[m].src); - rendered.push(images === "alt" ? "![" + alt + "](" + dest + ")" : "[" + alt + "](" + dest + ")"); + for (var m = 0; m < allImages.length; m++) { + if (!splicedImage[m]) rendered.push(imageMarkdown(allImages[m])); } if (rendered.length) add(rendered.join("\n"), 4); } diff --git a/test.js b/test.js index 7327f8c..7c1c95a 100644 --- a/test.js +++ b/test.js @@ -652,6 +652,7 @@ test("extract reports machine-readable capture-completeness diagnostics", () => assert.strictEqual(clean.diagnostics.maxBlocksHit, false); assert.strictEqual(clean.diagnostics.droppedBlockCount, 0); assert.strictEqual(clean.diagnostics.unpositionedTables, 0); + assert.strictEqual(clean.diagnostics.unpositionedImages, 0); assert.ok(!clean.warnings.includes("blocks_truncated")); }); test("toMarkdown leaves layout tables out of the prose flow", () => { @@ -665,6 +666,71 @@ test("toMarkdown leaves layout tables out of the prose flow", () => { // a layout table wrapping a paragraph must not be spliced inline assert.strictEqual(art.tables[0].position, undefined, "layout table is not given a flow position"); }); +test("toMarkdown renders content images at their original position in the prose", () => { + const doc = new JSDOM(`
+

Field Notes

+

Opening paragraph with enough text to clear the extraction length floor.

+
Waves at the shore
+

Trailing paragraph with sufficient length to be retained in the output.

+
`, { url: "https://example.com/notes" }).window.document; + const art = Mantis.extract(doc); + assert.strictEqual(typeof art.images[0].position, "number", "content image gets a flow position"); + assert.strictEqual(art.diagnostics.unpositionedImages, 0); + const md = Mantis.toMarkdown(art, { images: "alt" }); + const img = md.indexOf("![Waves at the shore](https://example.com/shore.jpg)"); + assert.ok(img > -1, "image is rendered"); + assert.ok(md.indexOf("Opening paragraph") < img, "image follows the paragraph before it"); + assert.ok(img < md.indexOf("Trailing paragraph"), "image precedes the paragraph after it"); +}); +test("toMarkdown renders a leading image before the first paragraph", () => { + const doc = new JSDOM(`
+
Hero shot
+

Gallery Opener

+

First paragraph with enough text to clear the extraction length floor.

+

Second paragraph with sufficient length to be retained in the output.

+
`, { url: "https://example.com/g" }).window.document; + const art = Mantis.extract(doc); + assert.strictEqual(art.images[0].position, -1, "image before all blocks anchors at -1"); + const md = Mantis.toMarkdown(art, { images: "alt" }); + const img = md.indexOf("![Hero shot](https://example.com/hero.jpg)"); + assert.ok(img > -1 && img < md.indexOf("First paragraph"), "image leads the prose"); +}); +test("toMarkdown renders an image between the title heading and the first paragraph", () => { + const doc = new JSDOM(`
+

Gallery Opener

+
Hero shot
+

First paragraph with enough text to clear the extraction length floor.

+

Second paragraph with sufficient length to be retained in the output.

+
`, { url: "https://example.com/g2" }).window.document; + const art = Mantis.extract(doc); + assert.strictEqual(art.images[0].position, 0, "image anchors after the heading block"); + const md = Mantis.toMarkdown(art, { images: "alt" }); + const img = md.indexOf("![Hero shot](https://example.com/hero.jpg)"); + assert.ok(img > -1 && img < md.indexOf("First paragraph"), "image stays ahead of the prose"); +}); +test("toMarkdown keeps unpositioned images in a trailing list", () => { + const md = Mantis.toMarkdown({ + title: "Stored", + blocks: [ + { type: "paragraph", text: "First stored paragraph." }, + { type: "paragraph", text: "Second stored paragraph." } + ], + images: [{ src: "https://example.com/old.png", alt: "Old image" }] + }, { images: "alt" }); + assert.ok(md.indexOf("Second stored paragraph.") < md.indexOf("![Old image](https://example.com/old.png)"), + "image without a position falls back to the document tail"); +}); +test("extract strips transient DOM references from images", () => { + const doc = new JSDOM(`
+

Clean JSON

+

Paragraph with enough text to clear the extraction length floor here.

+
Pic
+
`, { url: "https://example.com/j" }).window.document; + const art = Mantis.extract(doc); + assert.ok(!("__el" in art.images[0]), "no __el left on images"); + assert.ok(art.blocks.every((b) => !("__el" in b)), "no __el left on blocks"); + JSON.stringify(art); // must not throw on circular DOM references +}); test("toMarkdown still renders stored articles without runs", () => { const md = Mantis.toMarkdown({ title: "Old", From c3ac727f9c40022780049fe18abfe11a5b479b6a Mon Sep 17 00:00:00 2001 From: yrstm <12587046+yrstm@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:42:11 +0100 Subject: [PATCH 2/2] Fix inline media ordering and fallbacks --- mantis.d.ts | 4 ++ mantis.js | 121 ++++++++++++++++++++++++++++++++++------------------ test.js | 59 +++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 41 deletions(-) diff --git a/mantis.d.ts b/mantis.d.ts index 25ea879..ce1b812 100644 --- a/mantis.d.ts +++ b/mantis.d.ts @@ -24,6 +24,8 @@ export interface MantisImage { * articles, which render images in a list at the end instead. */ position?: number; + /** DOM order shared by positioned images and tables at the same block anchor. */ + flowOrder?: number; } export interface MantisTable { @@ -39,6 +41,8 @@ export interface MantisTable { * maxBlocks, which are appended at the end instead. */ position?: number; + /** DOM order shared by positioned tables and images at the same block anchor. */ + flowOrder?: number; } export interface MantisInlineRun { diff --git a/mantis.js b/mantis.js index 21e13ec..0901528 100644 --- a/mantis.js +++ b/mantis.js @@ -669,24 +669,36 @@ return anchor; } - function positionTables(blocks, tables, scope) { + // Position tables and images together. `position` anchors each item after a + // block; `flowOrder` preserves DOM order when unlike items share that anchor. + // Stored or vision articles without either value keep the trailing fallback. + function positionFlowItems(blocks, tables, images, scope) { + var flow = []; + var sequence = 0; for (var t = 0; t < (tables ? tables.length : 0); t++) { - var el = tables[t].__el; - if (el && isDataTableEl(el, scope)) tables[t].position = anchorIndex(blocks, el); - delete tables[t].__el; + var tableEl = tables[t].__el; + if (tableEl && isDataTableEl(tableEl, scope)) { + flow.push({ item: tables[t], el: tableEl, sequence: sequence++ }); + } } - } - - // Record where each content image sits relative to the captured blocks so - // toMarkdown can render it at its place in the prose instead of in a list at - // the document tail. Images without a DOM element (vision pipeline, stored - // articles) get no position and keep the trailing-list fallback. - function positionImages(blocks, images) { for (var m = 0; m < (images ? images.length : 0); m++) { - var el = images[m].__el; - if (el) images[m].position = anchorIndex(blocks, el); - delete images[m].__el; + var imageEl = images[m].__el; + if (imageEl) flow.push({ item: images[m], el: imageEl, sequence: sequence++ }); + } + flow.sort(function (a, b) { + if (a.el === b.el) return a.sequence - b.sequence; + var rel = a.el.compareDocumentPosition(b.el); + if (rel & 1) return a.sequence - b.sequence; // disconnected: stable fallback + if (rel & 4) return -1; // b follows a + if (rel & 2) return 1; // b precedes a + return a.sequence - b.sequence; + }); + for (var f = 0; f < flow.length; f++) { + flow[f].item.position = anchorIndex(blocks, flow[f].el); + flow[f].item.flowOrder = f; } + for (var kt = 0; kt < (tables ? tables.length : 0); kt++) delete tables[kt].__el; + for (var km = 0; km < (images ? images.length : 0); km++) delete images[km].__el; } function meta(doc, name) { @@ -850,8 +862,7 @@ }; // anchor data tables and content images to their position in the block // flow; strips the transient DOM references before any serialization - positionTables(article.blocks, article.tables, scope || doc.body); - positionImages(article.blocks, article.images); + positionFlowItems(article.blocks, article.tables, article.images, scope || doc.body); for (var kb = 0; kb < article.blocks.length; kb++) delete article.blocks[kb].__el; // tables and images that were not spliced into the flow are appended for (var ut = 0; ut < article.tables.length; ut++) { @@ -1025,32 +1036,58 @@ // document. They are spliced into the flow below. Items without a position // (layout/nested tables, vision-pipeline captures, stored articles) are // appended at the end, which preserves prior behavior and never drops data. + function validFlowPosition(position) { + return typeof position === "number" && isFinite(position) && + Math.floor(position) === position && position >= -1 && position < blocks.length; + } + function validFlowOrder(order) { + return typeof order === "number" && isFinite(order); + } + var flowAt = {}; + var preFlow = []; + var flowSequence = 0; + function queueFlow(position, kind, index, order) { + var entry = { + kind: kind, + index: index, + order: validFlowOrder(order) ? order : null, + sequence: flowSequence++ + }; + if (position < 0) preFlow.push(entry); + else (flowAt[position] = flowAt[position] || []).push(entry); + } + function sortFlow(entries) { + entries.sort(function (a, b) { + if (a.order !== null && b.order !== null && a.order !== b.order) return a.order - b.order; + if (a.order !== null && b.order === null) return -1; + if (a.order === null && b.order !== null) return 1; + return a.sequence - b.sequence; + }); + } var renderTables = options.tables !== false; var allTables = article.tables || []; - var tablesAt = {}; - var preTables = []; var splicedTable = []; if (renderTables) { for (var ti = 0; ti < allTables.length; ti++) { var pos = allTables[ti].position; - if (typeof pos !== "number") continue; + if (!validFlowPosition(pos)) continue; splicedTable[ti] = true; - if (pos < 0) preTables.push(ti); - else (tablesAt[pos] = tablesAt[pos] || []).push(ti); + queueFlow(pos, "table", ti, allTables[ti].flowOrder); } } var renderImages = images === "alt" || images === "links"; var allImages = renderImages ? (article.images || []) : []; - var imagesAt = {}; - var preImages = []; var splicedImage = []; for (var mi = 0; mi < allImages.length; mi++) { var ipos = allImages[mi].position; - // out-of-range anchors (hand-edited or truncated articles) fall back - if (typeof ipos !== "number" || ipos >= blocks.length) continue; + // Invalid anchors on hand-edited or truncated articles fall back. + if (!validFlowPosition(ipos)) continue; splicedImage[mi] = true; - if (ipos < 0) preImages.push(mi); - else (imagesAt[ipos] = imagesAt[ipos] || []).push(mi); + queueFlow(ipos, "image", mi, allImages[mi].flowOrder); + } + sortFlow(preFlow); + for (var flowKey in flowAt) { + if (Object.prototype.hasOwnProperty.call(flowAt, flowKey)) sortFlow(flowAt[flowKey]); } function imageMarkdown(img) { var alt = escapeInline(img.alt || "image"); @@ -1062,27 +1099,29 @@ var flushedUpTo = -1; function flushInlineThrough(idx) { for (var a = flushedUpTo + 1; a <= idx; a++) { - var here = tablesAt[a]; - if (here) { - for (var h = 0; h < here.length; h++) { - add(tableMarkdown(allTables[here[h]]), lead ? 2 : 3); + var here = flowAt[a]; + if (!here) continue; + for (var h = 0; h < here.length; h++) { + if (here[h].kind === "table") { + add(tableMarkdown(allTables[here[h].index]), lead ? 2 : 3); lead = false; + } else { + // Images keep image priority, so the outline budget still sheds + // them first; they do not consume `lead`. + add(imageMarkdown(allImages[here[h].index]), 4); } } - // images render at their block anchor but keep image priority, so the - // outline budget still sheds them first; they do not consume `lead` - var ihere = imagesAt[a]; - if (ihere) { - for (var g = 0; g < ihere.length; g++) add(imageMarkdown(allImages[ihere[g]]), 4); - } } flushedUpTo = idx; } - for (var pt = 0; pt < preTables.length; pt++) { - add(tableMarkdown(allTables[preTables[pt]]), 2); - lead = false; + for (var pf = 0; pf < preFlow.length; pf++) { + if (preFlow[pf].kind === "table") { + add(tableMarkdown(allTables[preFlow[pf].index]), 2); + lead = false; + } else { + add(imageMarkdown(allImages[preFlow[pf].index]), 4); + } } - for (var pi = 0; pi < preImages.length; pi++) add(imageMarkdown(allImages[preImages[pi]]), 4); for (var i = 0; i < blocks.length; i++) { var b = blocks[i]; // the page H1 usually repeats the title; emit it once diff --git a/test.js b/test.js index 7c1c95a..2a0656d 100644 --- a/test.js +++ b/test.js @@ -682,6 +682,65 @@ test("toMarkdown renders content images at their original position in the prose" assert.ok(md.indexOf("Opening paragraph") < img, "image follows the paragraph before it"); assert.ok(img < md.indexOf("Trailing paragraph"), "image precedes the paragraph after it"); }); +test("toMarkdown preserves image and table DOM order at a shared block anchor", () => { + function render(middle) { + const doc = new JSDOM(`
+

Mixed Flow

+

Opening paragraph with enough text to clear the extraction length floor.

+ ${middle} +

Trailing paragraph with sufficient length to be retained in the output.

+
`, { url: "https://example.com/mixed" }).window.document; + const article = Mantis.extract(doc); + return { + article, + markdown: Mantis.toMarkdown(article, { images: "alt" }) + }; + } + + const image = `
Flow image
`; + const table = ` +
ItemValue
AlphaOne
`; + + const imageFirst = render(image + table); + assert.strictEqual(imageFirst.article.images[0].position, imageFirst.article.tables[0].position, + "adjacent image and table share a block anchor"); + assert.ok(imageFirst.markdown.indexOf("![Flow image]") < imageFirst.markdown.indexOf("| Item | Value |"), + "image before table in the DOM stays before it in Markdown"); + + const tableFirst = render(table + image); + assert.strictEqual(tableFirst.article.images[0].position, tableFirst.article.tables[0].position, + "reverse-order image and table share a block anchor"); + assert.ok(tableFirst.markdown.indexOf("| Item | Value |") < tableFirst.markdown.indexOf("![Flow image]"), + "table before image in the DOM stays before it in Markdown"); +}); +test("toMarkdown sends invalid image and table positions to the trailing fallback", () => { + const invalidPositions = [0.5, NaN, -2, 2, Infinity, -Infinity]; + for (const position of invalidPositions) { + const markdown = Mantis.toMarkdown({ + title: "Stored", + blocks: [ + { type: "paragraph", text: "First stored paragraph." }, + { type: "paragraph", text: "Second stored paragraph." } + ], + tables: [{ + caption: "", + headers: ["Item", "Value"], + rows: [["Alpha", "One"]], + position + }], + images: [{ + src: "https://example.com/fallback.png", + alt: "Fallback image", + position + }] + }, { images: "alt" }); + const tail = markdown.indexOf("Second stored paragraph."); + assert.ok(tail < markdown.indexOf("| Item | Value |"), + `table position ${String(position)} falls back after the prose`); + assert.ok(tail < markdown.indexOf("![Fallback image]"), + `image position ${String(position)} falls back after the prose`); + } +}); test("toMarkdown renders a leading image before the first paragraph", () => { const doc = new JSDOM(`
Hero shot