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..ce1b812 100644 --- a/mantis.d.ts +++ b/mantis.d.ts @@ -17,6 +17,15 @@ 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; + /** DOM order shared by positioned images and tables at the same block anchor. */ + flowOrder?: number; } export interface MantisTable { @@ -32,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 { @@ -98,6 +109,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..0901528 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,51 @@ // 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. - function positionTables(blocks, tables, scope) { + // 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; + } + + // 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)) { - 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; + var tableEl = tables[t].__el; + if (tableEl && isDataTableEl(tableEl, scope)) { + flow.push({ item: tables[t], el: tableEl, sequence: sequence++ }); } - delete tables[t].__el; } - for (var k = 0; k < blocks.length; k++) delete blocks[k].__el; + for (var m = 0; m < (images ? images.length : 0); m++) { + 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) { @@ -828,16 +856,21 @@ 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 - positionTables(article.blocks, article.tables, scope || doc.body); - // tables that were not spliced into the flow (layout/nested) are appended + // anchor data tables and content images to their position in the block + // flow; strips the transient DOM references before any serialization + 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++) { 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,55 +1031,109 @@ 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. + 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 splicedImage = []; + for (var mi = 0; mi < allImages.length; mi++) { + var ipos = allImages[mi].position; + // Invalid anchors on hand-edited or truncated articles fall back. + if (!validFlowPosition(ipos)) continue; + splicedImage[mi] = true; + 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"); + 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]; + var here = flowAt[a]; if (!here) continue; for (var h = 0; h < here.length; h++) { - add(tableMarkdown(allTables[here[h]]), lead ? 2 : 3); - lead = false; + 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); + } } } 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 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 +1152,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 +1160,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..2a0656d 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,130 @@ 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 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
+

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",