diff --git a/.gitignore b/.gitignore index 9292ae7..8b01dc0 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ data.json .claude/settings.local.json .claude/worktrees/ .claude/worktrees/* +.integration-out/ diff --git a/manifest.json b/manifest.json index c497796..115fe21 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "hexmaker", "name": "Hexmap World Creator", - "version": "1.1.15", + "version": "1.2.0", "minAppVersion": "1.12.0", "description": "Create a hex map and a guidebook level wiki for your setting with minimal fuss. Hex crawl worldbuilding toolbox. System agnostic, ready for any TRPG you care to run in it.", "author": "morkdev", diff --git a/package-lock.json b/package-lock.json index 77b9508..2ed793a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hexmaker-plugin", - "version": "1.1.15", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hexmaker-plugin", - "version": "1.1.15", + "version": "1.2.0", "license": "MIT", "dependencies": { "minisearch": "^7.2.0" diff --git a/package.json b/package.json index bda5470..05d38d0 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,16 @@ { "name": "hexmaker-plugin", - "version": "1.1.15", + "version": "1.2.0", "description": "A plugin to power hex crawl worldbuilding and running.", "main": "main.js", "scripts": { "dev": "node esbuild.config.mjs", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "version": "node version-bump.mjs && git add manifest.json versions.json", - "test": "tsx --tsconfig ./tsconfig.test.json --import ./tests/register.mjs --test tests/frontmatter.test.ts tests/hexEditorModal.test.ts tests/randomTable.test.ts tests/sections.test.ts tests/utils.test.ts tests/workflow.test.ts tests/token.test.ts", - "test:watch": "tsx --tsconfig ./tsconfig.test.json --import ./tests/register.mjs --test --watch tests/frontmatter.test.ts tests/hexEditorModal.test.ts tests/randomTable.test.ts tests/sections.test.ts tests/utils.test.ts tests/workflow.test.ts tests/token.test.ts", + "test": "tsx --tsconfig ./tsconfig.test.json --import ./tests/register.mjs --test tests/frontmatter.test.ts tests/hexEditorModal.test.ts tests/randomTable.test.ts tests/sections.test.ts tests/utils.test.ts tests/workflow.test.ts tests/token.test.ts tests/randomTableExport.test.ts tests/singleNoteExport.test.ts tests/hexGeometry.test.ts tests/mapWithTableExport.test.ts tests/singleHexExport.test.ts tests/workflowExport.test.ts", + "test:watch": "tsx --tsconfig ./tsconfig.test.json --import ./tests/register.mjs --test --watch tests/frontmatter.test.ts tests/hexEditorModal.test.ts tests/randomTable.test.ts tests/sections.test.ts tests/utils.test.ts tests/workflow.test.ts tests/token.test.ts tests/randomTableExport.test.ts tests/singleNoteExport.test.ts tests/hexGeometry.test.ts tests/mapWithTableExport.test.ts tests/singleHexExport.test.ts tests/workflowExport.test.ts", + "test:integration": "tsx --tsconfig ./tsconfig.test.json --import ./tests/register.mjs --test tests/integration/*.integration.ts", + "test:e2e": "tsx --tsconfig ./tsconfig.test.json --import ./tests/register.mjs --test tests/integration/e2e-*.integration.ts", "lint": "eslint . --ext .js,.ts" }, "keywords": [ diff --git a/src/HexmakerPlugin.ts b/src/HexmakerPlugin.ts index f6469c3..e1b3366 100644 --- a/src/HexmakerPlugin.ts +++ b/src/HexmakerPlugin.ts @@ -1,4 +1,10 @@ -import { Editor, EventRef, Menu, Notice, Plugin, TAbstractFile, TFile, TFolder } from "obsidian"; +import { Editor, EventRef, MarkdownView, Menu, Notice, Plugin, TAbstractFile, TFile, TFolder } from "obsidian"; +import { + exportSingleNoteAsPdf, + exportSingleNoteAsMarkdown, +} from "./export/exporters/singleNote"; +import { HexExportModal } from "./hex-map/HexExportModal"; +import { WorkflowExportModal } from "./random-tables/WorkflowExportModal"; import { HexMapView } from "./hex-map/HexMapView"; import { HexTableView } from "./hex-table/HexTableView"; import { RandomTableView } from "./random-tables/RandomTableView"; @@ -85,6 +91,66 @@ export default class HexmakerPlugin extends Plugin { .getLeaf() .setViewState({ type: VIEW_TYPE_RANDOM_TABLES }), }); + this.addCommand({ + id: "export-current-note-pdf", + name: "Export current note to PDF", + checkCallback: (checking) => { + const file = this.app.workspace.getActiveViewOfType(MarkdownView)?.file; + if (!file) return false; + if (!checking) void exportSingleNoteAsPdf(this, file); + return true; + }, + }); + this.addCommand({ + id: "export-current-note-markdown", + name: "Export current note to Markdown", + checkCallback: (checking) => { + const file = this.app.workspace.getActiveViewOfType(MarkdownView)?.file; + if (!file) return false; + if (!checking) void exportSingleNoteAsMarkdown(this, file); + return true; + }, + }); + this.addCommand({ + id: "export-current-hex", + name: "Export current hex (structured PDF / Markdown)", + checkCallback: (checking) => { + const file = this.app.workspace.getActiveViewOfType(MarkdownView)?.file; + if (!file || !this.isHexFile(file)) return false; + if (!checking) new HexExportModal(this.app, this, file).open(); + return true; + }, + }); + this.addCommand({ + id: "export-current-workflow", + name: "Export current workflow with rolled samples", + checkCallback: (checking) => { + const file = this.app.workspace.getActiveViewOfType(MarkdownView)?.file; + if (!file || !this.isWorkflowFile(file)) return false; + if (!checking) new WorkflowExportModal(this.app, this, file).open(); + return true; + }, + }); + // File-menu items: right-click any markdown file → Export to PDF / Markdown + this.registerEvent( + this.app.workspace.on("file-menu", (menu, file) => { + if (!(file instanceof TFile) || file.extension !== "md") return; + menu.addItem((item) => + item + .setTitle("Export to PDF") + .setIcon("file-down") + .setSection("action") + .onClick(() => void exportSingleNoteAsPdf(this, file)), + ); + menu.addItem((item) => + item + .setTitle("Export to Markdown") + .setIcon("file-text") + .setSection("action") + .onClick(() => void exportSingleNoteAsMarkdown(this, file)), + ); + }), + ); this.addSettingTab(new HexmakerSettingTab(this.app, this)); // Register the embedded roller code block processor @@ -477,6 +543,27 @@ export default class HexmakerPlugin extends Plugin { ); } + /** + * Check whether a TFile is a workflow note (lives under the configured + * workflows folder). Used to gate the workflow export command. + */ + isWorkflowFile(file: TFile): boolean { + const folder = normalizeFolder(this.settings.workflowsFolder ?? ""); + if (!folder) return false; + return file.path.startsWith(folder + "/") && !file.basename.startsWith("_"); + } + + /** + * Check whether a TFile is a hex note (lives under the configured hex + * folder, basename matches the `x_y` coord pattern). Used to gate the + * structured hex export command. + */ + isHexFile(file: TFile): boolean { + const hexFolder = normalizeFolder(this.settings.hexFolder); + if (hexFolder && !file.path.startsWith(hexFolder + "/")) return false; + return /^-?\d+_-?\d+$/.test(file.basename); + } + hexPath(x: number, y: number, mapName: string): string { const folder = normalizeFolder(this.settings.hexFolder); return folder diff --git a/src/constants.ts b/src/constants.ts index 4c6dd88..cf6dc04 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -331,6 +331,7 @@ export const DEFAULT_SETTINGS: HexmakerPluginSettings = { defaultSubmapCols: 10, defaultSubmapRows: 10, workflowsFolder: "", + exportFolder: "", hiddenIcons: [], iconOrder: [], setupComplete: false, diff --git a/src/export/exportFolder.ts b/src/export/exportFolder.ts new file mode 100644 index 0000000..7919c41 --- /dev/null +++ b/src/export/exportFolder.ts @@ -0,0 +1,38 @@ +/** + * Helpers for resolving and creating the export destination folder. + */ + +import type { App } from "obsidian"; +import { normalizeFolder } from "../utils"; +import type HexmakerPlugin from "../HexmakerPlugin"; + +/** + * Resolve the configured export folder, defaulting to `{worldFolder}/exports` + * (or just `exports` if `worldFolder` is empty). Returns a normalized path + * suitable for vault operations. + */ +export function resolveExportFolder(plugin: HexmakerPlugin): string { + const configured = normalizeFolder(plugin.settings.exportFolder ?? ""); + if (configured) return configured; + const world = normalizeFolder(plugin.settings.worldFolder ?? ""); + return world ? `${world}/exports` : "exports"; +} + +/** + * Ensure the export folder (and any missing ancestors) exist. Idempotent. + */ +export async function ensureExportFolder(plugin: HexmakerPlugin): Promise { + const folder = resolveExportFolder(plugin); + await ensureFolder(plugin.app, folder); + return folder; +} + +/** Create the folder if missing. Quietly tolerates concurrent creation. */ +async function ensureFolder(app: App, path: string): Promise { + if (app.vault.getAbstractFileByPath(path)) return; + try { + await app.vault.createFolder(path); + } catch { + /* race: already exists */ + } +} diff --git a/src/export/exporters/mapWithTable.ts b/src/export/exporters/mapWithTable.ts new file mode 100644 index 0000000..2fa07bc --- /dev/null +++ b/src/export/exporters/mapWithTable.ts @@ -0,0 +1,456 @@ +/** + * Map → PDF with reference table exporter (Phase 1, item 1.4). + * + * Document structure: + * Page 1 — Title + full map rendered as large as fits on the page + * Page 2 — Table of contents listing each section + its hex range + * Pages 3+ — One page per section: cropped map view + hex reference table + * filtered to that section's hexes + * + * Subdivision picks the smallest number of sections such that each section's + * hexes render at least `minHexPxOnPage` wide on the section page. For small + * maps this is 1×1 (no subdivision); for large maps it scales up. + * + * The hex table captures every link section (Towns, Dungeons, Features, + * Quests, Factions, Encounters Table) plus terrain and description so the + * printed PDF is a complete offline reference for the map. + */ + +import { Notice, TFile, type App } from "obsidian"; +import { exportToPdfBytes } from "../pdfExporter"; +import { renderMarkdownToHtml } from "../htmlRenderer"; +import { ensureExportFolder } from "../exportFolder"; +import { + renderMapToPngBlob, + type MapPngRenderOptions, +} from "../mapPngRenderer"; +import { getAllSectionData } from "../../sections"; +import { getTerrainFromFile } from "../../frontmatter"; +import type HexmakerPlugin from "../../HexmakerPlugin"; + +export interface MapPdfExportOptions extends MapPngRenderOptions { + /** Filename stem (no extension). Defaults to mapName. */ + outputName?: string; + /** Orientation. Defaults to landscape (better for map-heavy docs). */ + landscape?: boolean; + /** Page size. Defaults to Letter. */ + pageSize?: "A4" | "Letter" | "Legal" | "Tabloid"; + /** + * Minimum pixel width of a single hex when displayed on a section page. + * Drives the subdivision count. Default 60. + */ + minHexPxOnPage?: number; +} + +const DEFAULT_MIN_HEX_PX = 60; +// Approximate usable area on a landscape Letter page (after margins) for the +// section-page map view. Tuned with the actual print CSS in printScaffold.ts. +const SECTION_MAP_PAGE_PX = { width: 950, height: 360 }; + +export async function exportMapAsPdf( + plugin: HexmakerPlugin, + mapName: string, + opts: MapPdfExportOptions = {}, +): Promise { + const folder = await ensureExportFolder(plugin); + const stem = (opts.outputName ?? mapName).trim() || mapName; + const outPath = `${folder}/${sanitiseFilename(stem)}.pdf`; + + const notice = new Notice(`Exporting ${stem}.pdf…`, 0); + try { + const map = plugin.settings.maps.find((m) => m.name === mapName); + if (!map) throw new Error(`Map "${mapName}" not found`); + + // 1. Compute subdivisions + const subdivisions = computeSubdivisions({ + gridCols: map.gridSize.cols, + gridRows: map.gridSize.rows, + pageWidthPx: SECTION_MAP_PAGE_PX.width, + pageHeightPx: SECTION_MAP_PAGE_PX.height, + minHexPxOnPage: opts.minHexPxOnPage ?? DEFAULT_MIN_HEX_PX, + }); + + // 2. Render the full map for the cover page + const fullPng = await renderMapToPngBlob(plugin, mapName, opts); + const fullDataUri = await blobToDataUri(fullPng, "image/png"); + + // 3. Render each section's cropped PNG + const sections = enumerateSections(subdivisions, map.gridSize); + const sectionImages: string[] = []; + for (const section of sections) { + const sectionPng = await renderMapToPngBlob(plugin, mapName, { + ...opts, + subgrid: { + colStart: section.colStart, + colEnd: section.colEnd, + rowStart: section.rowStart, + rowEnd: section.rowEnd, + }, + }); + sectionImages.push(await blobToDataUri(sectionPng, "image/png")); + } + + // 4. Build the markdown document + const md = await buildMapPdfMarkdown( + plugin, + mapName, + fullDataUri, + sections, + sectionImages, + ); + + // 5. Render to HTML and PDF + const rendered = await renderMarkdownToHtml({ + app: plugin.app, + title: mapName, + sourcePath: `${mapName}.export.md`, + markdown: md, + }); + const pdfBytes = await exportToPdfBytes(rendered, { + pageSize: opts.pageSize ?? "Letter", + landscape: opts.landscape ?? true, + displayHeaderFooter: true, + footerTemplate: `
/
`, + }); + + await writeBinaryToVault(plugin.app, outPath, pdfBytes); + new Notice(`Exported to ${outPath}`); + void openInVault(plugin.app, outPath); + } catch (err) { + console.error(err); + new Notice(`Export failed: ${(err as Error).message ?? err}`); + } finally { + notice.hide(); + } +} + +// ── Subdivision math ────────────────────────────────────────────────────── + +export interface SubdivisionParams { + gridCols: number; + gridRows: number; + pageWidthPx: number; + pageHeightPx: number; + minHexPxOnPage: number; +} + +export interface SubdivisionLayout { + colSections: number; + rowSections: number; + /** Hexes per section (in cols × rows). The last section in each axis may + * be smaller; this is the ceiling. */ + hexesPerSectionCol: number; + hexesPerSectionRow: number; +} + +/** + * Pick the minimum number of sections such that each section's hexes have at + * least `minHexPxOnPage` of horizontal space on the printed page. Pure. + */ +export function computeSubdivisions(p: SubdivisionParams): SubdivisionLayout { + const SQRT3_2 = Math.sqrt(3) / 2; + // The map view on a section page has bounded width/height. Pick the smallest + // subdivision such that the page can fit (hexesPerSectionCol × hexPx) in + // its width AND (hexesPerSectionRow × hexPx × √3/2) in its height. + const maxHexesPerWidth = Math.max( + 1, + Math.floor(p.pageWidthPx / p.minHexPxOnPage), + ); + const maxHexesPerHeight = Math.max( + 1, + Math.floor(p.pageHeightPx / (p.minHexPxOnPage * SQRT3_2)), + ); + const colSections = Math.max(1, Math.ceil(p.gridCols / maxHexesPerWidth)); + const rowSections = Math.max(1, Math.ceil(p.gridRows / maxHexesPerHeight)); + return { + colSections, + rowSections, + hexesPerSectionCol: Math.ceil(p.gridCols / colSections), + hexesPerSectionRow: Math.ceil(p.gridRows / rowSections), + }; +} + +export interface MapSection { + /** Human-readable label, e.g. "A1", "A2", "B1". */ + label: string; + colStart: number; + colEnd: number; // half-open: hexes in [colStart, colEnd) + rowStart: number; + rowEnd: number; +} + +/** + * Enumerate the sections produced by a subdivision in row-major order + * (left-to-right then top-to-bottom). Labels use column letters A, B, C… + * and row numbers 1, 2, 3… so the first section is "A1", the one to its + * right is "B1", and so on. Pure. + */ +export function enumerateSections( + layout: SubdivisionLayout, + gridSize: { cols: number; rows: number }, +): MapSection[] { + const out: MapSection[] = []; + for (let rs = 0; rs < layout.rowSections; rs++) { + for (let cs = 0; cs < layout.colSections; cs++) { + const colStart = cs * layout.hexesPerSectionCol; + const rowStart = rs * layout.hexesPerSectionRow; + out.push({ + label: `${String.fromCharCode(65 + cs)}${rs + 1}`, + colStart, + colEnd: Math.min(gridSize.cols, colStart + layout.hexesPerSectionCol), + rowStart, + rowEnd: Math.min(gridSize.rows, rowStart + layout.hexesPerSectionRow), + }); + } + } + return out; +} + +// ── Markdown builder ────────────────────────────────────────────────────── + +/** + * Build the full markdown document. Public for unit testing. + */ +export async function buildMapPdfMarkdown( + plugin: HexmakerPlugin, + mapName: string, + fullMapImageUri: string, + sections: MapSection[], + sectionImageUris: string[], +): Promise { + const map = plugin.settings.maps.find((m) => m.name === mapName); + if (!map) throw new Error(`Map "${mapName}" not found`); + + const lines: string[] = []; + + // ── Page 1: Cover with full map ─────────────────────────────────────── + lines.push(`# ${mapName}`); + lines.push(""); + lines.push( + `${escapeHtmlAttr(mapName)} full map`, + ); + lines.push(""); + + // ── Page 2: Section TOC ─────────────────────────────────────────────── + lines.push(`
`); + lines.push(""); + lines.push(`## Section index`); + lines.push(""); + lines.push("| Section | Hex range (col, row) |"); + lines.push("| --- | --- |"); + for (const s of sections) { + const { x: ox, y: oy } = map.gridOffset; + lines.push( + `| **${s.label}** | (${s.colStart + ox}, ${s.rowStart + oy}) — (${s.colEnd - 1 + ox}, ${s.rowEnd - 1 + oy}) |`, + ); + } + lines.push(""); + + // ── Pages 3+: One per section ───────────────────────────────────────── + for (let i = 0; i < sections.length; i++) { + const s = sections[i]; + const imgUri = sectionImageUris[i] ?? ""; + lines.push(`
`); + lines.push(""); + lines.push(`## Section ${s.label}`); + lines.push(""); + lines.push( + `${escapeHtmlAttr(mapName)} section ${s.label}`, + ); + lines.push(""); + + const rows = await collectHexRows(plugin, mapName, s); + if (rows.length === 0) { + lines.push(`*No hex notes with content in this section.*`); + continue; + } + const headers = [ + "Hex", + "Terrain", + "Towns", + "Dungeons", + "Features", + "Quests", + "Factions", + "Encounters", + "Description", + ]; + lines.push(`| ${headers.join(" | ")} |`); + lines.push(`| ${headers.map(() => "---").join(" | ")} |`); + for (const row of rows) { + const cells = [ + row.hex, + row.terrain, + row.towns, + row.dungeons, + row.features, + row.quests, + row.factions, + row.encounters, + row.description, + ].map(escapePipes); + lines.push(`| ${cells.join(" | ")} |`); + } + } + return lines.join("\n"); +} + +// ── Hex row collection ──────────────────────────────────────────────────── + +interface HexRow { + hex: string; + terrain: string; + towns: string; + dungeons: string; + features: string; + quests: string; + factions: string; + encounters: string; + description: string; +} + +async function collectHexRows( + plugin: HexmakerPlugin, + mapName: string, + section: MapSection, +): Promise { + const map = plugin.settings.maps.find((m) => m.name === mapName); + if (!map) return []; + + const out: HexRow[] = []; + const { x: ox, y: oy } = map.gridOffset; + + for (let row = section.rowStart; row < section.rowEnd; row++) { + for (let col = section.colStart; col < section.colEnd; col++) { + const hx = col + ox; + const hy = row + oy; + const path = plugin.hexPath(hx, hy, mapName); + const file = plugin.app.vault.getAbstractFileByPath(path); + if (!(file instanceof TFile)) continue; + + const terrain = getTerrainFromFile(plugin.app, path) ?? ""; + const sections = await getAllSectionData(plugin.app, path); + const description = extractDescriptionExcerpt(sections.text); + const towns = joinBasenames(sections.links.get("towns")); + const dungeons = joinBasenames(sections.links.get("dungeons")); + const features = joinBasenames(sections.links.get("features")); + const quests = joinBasenames(sections.links.get("quests")); + const factions = joinBasenames(sections.links.get("factions")); + const encounters = joinBasenames( + sections.links.get("encounters table"), + ); + const empty = + !terrain && + !description && + !towns && + !dungeons && + !features && + !quests && + !factions && + !encounters; + if (empty) continue; + + out.push({ + hex: `${hx},${hy}`, + terrain, + towns, + dungeons, + features, + quests, + factions, + encounters, + description, + }); + } + } + return out; +} + +// ── Pure helpers (exported for tests) ───────────────────────────────────── + +/** + * Take the `### Description` section's first paragraph, strip wikilink syntax + * down to display text, collapse whitespace, and truncate. + */ +export function extractDescriptionExcerpt(text: Map): string { + const desc = text.get("description") ?? ""; + if (!desc) return ""; + const firstPara = desc.split(/\n\s*\n/)[0] ?? ""; + const stripped = firstPara.replace( + /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, + (_m: string, target: string, alias: string | undefined) => { + if (alias) return alias; + const slash = target.lastIndexOf("/"); + return (slash >= 0 ? target.slice(slash + 1) : target).replace( + /\.md$/i, + "", + ); + }, + ); + return truncate(stripped.replace(/\s+/g, " ").trim(), 140); +} + +/** + * Join wikilink targets down to basenames, comma-separated. Pure. + */ +export function joinBasenames(items: string[] | undefined): string { + if (!items || items.length === 0) return ""; + return items + .map((item) => { + const slash = item.lastIndexOf("/"); + const stem = slash >= 0 ? item.slice(slash + 1) : item; + return stem.replace(/\.md$/i, ""); + }) + .join(", "); +} + +function truncate(s: string, n: number): string { + if (s.length <= n) return s; + return s.slice(0, Math.max(0, n - 1)).trimEnd() + "…"; +} + +function escapePipes(s: string): string { + return s.replace(/\|/g, "\\|"); +} + +function escapeHtmlAttr(s: string): string { + return s.replace(/&/g, "&").replace(/"/g, """).replace(/|]/g, "_"); +} + +async function blobToDataUri(blob: Blob, type: string): Promise { + const bytes = new Uint8Array(await blob.arrayBuffer()); + const CHUNK = 0x8000; + const parts: string[] = []; + for (let i = 0; i < bytes.length; i += CHUNK) { + parts.push(String.fromCharCode(...bytes.subarray(i, i + CHUNK))); + } + return `data:${type};base64,${btoa(parts.join(""))}`; +} + +async function writeBinaryToVault( + app: App, + path: string, + data: Uint8Array, +): Promise { + const buf = data.buffer.slice( + data.byteOffset, + data.byteOffset + data.byteLength, + ) as ArrayBuffer; + const existing = app.vault.getAbstractFileByPath(path); + if (existing instanceof TFile) { + await app.vault.modifyBinary(existing, buf); + } else { + await app.vault.createBinary(path, buf); + } +} + +async function openInVault(app: App, path: string): Promise { + const file = app.vault.getAbstractFileByPath(path); + if (file instanceof TFile) { + await app.workspace.getLeaf(false).openFile(file); + } +} diff --git a/src/export/exporters/randomTable.ts b/src/export/exporters/randomTable.ts new file mode 100644 index 0000000..b8176a2 --- /dev/null +++ b/src/export/exporters/randomTable.ts @@ -0,0 +1,250 @@ +/** + * Random table exporter (Phase 1, item 1.1). + * + * Generates a printable one-pager from a random table file: + * # Table name + * *d100* + * description + * | # | Result | Weight | Odds | + * ... + * + * Two output paths share the same generated markdown: + * - PDF: renderMarkdownToHtml → exportToPdfBytes → vault.createBinary + * - MD: write the markdown directly + */ + +import { Notice, TFile, type App } from "obsidian"; +import { exportToPdfBytes } from "../pdfExporter"; +import { renderMarkdownToHtml } from "../htmlRenderer"; +import { ensureExportFolder } from "../exportFolder"; +import { serializeMarkdown, stripFrontmatter } from "../mdSerializer"; +import { + type RandomTable, + type RandomTableEntry, + parseRandomTable, + getDieRanges, +} from "../../random-tables/randomTable"; +import { normalizeFolder } from "../../utils"; +import type HexmakerPlugin from "../../HexmakerPlugin"; + +/** A linked note resolved to its display name + content (frontmatter stripped). */ +export interface LinkedNote { + name: string; + content: string; +} + +export async function exportRandomTableAsPdf( + plugin: HexmakerPlugin, + file: TFile, +): Promise { + const md = await buildExportMarkdown(plugin.app, file); + const folder = await ensureExportFolder(plugin); + const outPath = `${folder}/${file.basename}.pdf`; + + const notice = new Notice(`Exporting ${file.basename}.pdf…`, 0); + try { + const rendered = await renderMarkdownToHtml({ + app: plugin.app, + title: file.basename, + sourcePath: file.path, + markdown: md, + }); + const bytes = await exportToPdfBytes(rendered, { + pageSize: "Letter", + displayHeaderFooter: true, + footerTemplate: `
/
`, + }); + await writeBinaryToVault(plugin.app, outPath, bytes); + new Notice(`Exported to ${outPath}`); + void openInVault(plugin.app, outPath); + } catch (err) { + console.error(err); + new Notice(`Export failed: ${(err as Error).message ?? err}`); + } finally { + notice.hide(); + } +} + +export async function exportRandomTableAsMarkdown( + plugin: HexmakerPlugin, + file: TFile, +): Promise { + const md = await buildExportMarkdown(plugin.app, file); + const folder = await ensureExportFolder(plugin); + const outPath = `${folder}/${file.basename}.md`; + + try { + const finalMd = serializeMarkdown(md, { wikilinks: "preserve" }); + await writeTextToVault(plugin.app, outPath, finalMd); + new Notice(`Exported to ${outPath}`); + void openInVault(plugin.app, outPath); + } catch (err) { + console.error(err); + new Notice(`Export failed: ${(err as Error).message ?? err}`); + } +} + +/** Generate the export markdown for a random table file. */ +async function buildExportMarkdown(app: App, file: TFile): Promise { + const content = await app.vault.read(file); + const table = parseRandomTable(content); + const baseMd = formatRandomTableMarkdown(file.basename, table); + const linkedNotes = await loadLinkedNotesForTable(app, file, table); + const appendix = formatLinkedNotesAppendix(linkedNotes); + return appendix ? `${baseMd}\n\n${appendix}` : baseMd; +} + +/** + * Resolve each table entry to its backing note (if any) and read the content. + * Order is preserved; unresolved entries are skipped silently. Frontmatter is + * stripped from each note so the embed reads as content, not metadata. + */ +async function loadLinkedNotesForTable( + app: App, + sourceFile: TFile, + table: RandomTable, +): Promise { + const out: LinkedNote[] = []; + for (const entry of table.entries) { + const file = resolveEntryNote(app, table, entry, sourceFile.path); + if (!file) continue; + try { + const raw = await app.vault.read(file); + out.push({ name: file.basename, content: stripFrontmatter(raw) }); + } catch (err) { + console.warn(`Failed to read linked note ${file.path}:`, err); + } + } + return out; +} + +/** + * Resolve a single entry to its TFile, handling both wiki-link entries and + * linkedFolder-driven tables. Returns null when no matching file exists. + */ +function resolveEntryNote( + app: App, + table: RandomTable, + entry: RandomTableEntry, + sourcePath: string, +): TFile | null { + if (table.linkedFolder) { + const folder = normalizeFolder(table.linkedFolder); + // Strip any .md the user may have included in the entry. + const stem = entry.result.replace(/\.md$/i, ""); + const path = folder ? `${folder}/${stem}.md` : `${stem}.md`; + const f = app.vault.getAbstractFileByPath(path); + return f instanceof TFile ? f : null; + } + if (entry.isLink) { + return app.metadataCache.getFirstLinkpathDest(entry.result, sourcePath); + } + return null; +} + +/** + * Format a list of linked notes as a markdown appendix. Each entry is wrapped + * in a `
` so the whole entry (heading + + * content + any images) can be kept together via `break-inside: avoid-page` + * — preventing the heading from being orphaned at the bottom of a page when + * the rest of the content wouldn't fit. + * + * The first section is tagged with an extra class so its top border can + * visually mark the appendix-start (separating it from the table above). + * Subsequent sections use a lighter border for inter-entry separation; all + * styling lives in printScaffold.ts. + * + * Pure — exported for unit + integration tests. + */ +export function formatLinkedNotesAppendix(notes: LinkedNote[]): string { + if (notes.length === 0) return ""; + const sections = notes.map((n, i) => { + const cls = i === 0 + ? "duckmage-export-note duckmage-export-note-first" + : "duckmage-export-note"; + return ( + `
\n\n` + + `## ${n.name}\n\n` + + `${n.content.trim()}\n\n` + + `
` + ); + }); + return sections.join("\n\n"); +} + +export function formatRandomTableMarkdown(name: string, table: RandomTable): string { + const lines: string[] = []; + lines.push(`# ${name}`); + lines.push(""); + if (table.dice > 0) { + lines.push(`*Roll d${table.dice}*`); + lines.push(""); + } + if (table.description) { + lines.push(table.description.trim()); + lines.push(""); + } + + const ranges = table.dice > 0 ? getDieRanges(table) : null; + const hasWeights = table.entries.some((e) => e.weight !== 1); + + // Columns: # (row index) OR Roll (die range), Result, Weight (if any non-1). + // Odds are intentionally omitted — readers can eyeball the ranges/weights. + const headerCells = ranges ? ["Roll", "Result"] : ["#", "Result"]; + if (hasWeights) headerCells.push("Weight"); + + lines.push(`| ${headerCells.join(" | ")} |`); + lines.push(`| ${headerCells.map(() => "---").join(" | ")} |`); + + table.entries.forEach((entry, i) => { + const firstCol = ranges ? ranges[i] : String(i + 1); + const cells = [firstCol, escapePipes(entry.result)]; + if (hasWeights) cells.push(String(entry.weight)); + lines.push(`| ${cells.join(" | ")} |`); + }); + + return lines.join("\n"); +} + +function escapePipes(s: string): string { + return s.replace(/\|/g, "\\|"); +} + +async function writeBinaryToVault( + app: App, + path: string, + data: Uint8Array, +): Promise { + // vault.createBinary / modifyBinary require ArrayBuffer; copy out of the + // (possibly larger) underlying buffer to get a tight ArrayBuffer. + const buf = data.buffer.slice( + data.byteOffset, + data.byteOffset + data.byteLength, + ) as ArrayBuffer; + const existing = app.vault.getAbstractFileByPath(path); + if (existing instanceof TFile) { + await app.vault.modifyBinary(existing, buf); + } else { + await app.vault.createBinary(path, buf); + } +} + +async function writeTextToVault( + app: App, + path: string, + data: string, +): Promise { + const existing = app.vault.getAbstractFileByPath(path); + if (existing instanceof TFile) { + await app.vault.modify(existing, data); + } else { + await app.vault.create(path, data); + } +} + +async function openInVault(app: App, path: string): Promise { + const file = app.vault.getAbstractFileByPath(path); + if (file instanceof TFile) { + await app.workspace.getLeaf(false).openFile(file); + } +} diff --git a/src/export/exporters/singleHex.ts b/src/export/exporters/singleHex.ts new file mode 100644 index 0000000..6ac7121 --- /dev/null +++ b/src/export/exporters/singleHex.ts @@ -0,0 +1,263 @@ +/** + * Single-hex exporter (Phase 1, item 1.5). + * + * Renders one hex note as a fully-structured PDF with each section as its own + * generously-spaced block. Unlike the generic single-note exporter (1.2), this + * one *knows* about hex semantics: + * + * - Coordinate + terrain header at the top (pulled from filename + frontmatter) + * - Each section wrapped in `
` with + * CSS spacing + break-inside: avoid-page so a section never splits across + * a page boundary + * - GM-only sections (Hidden, Secret) gated behind an explicit toggle + * - Section rendering order driven by HEX_SECTIONS — the extension point + * for adding more (e.g. related random tables, rolled encounter samples) + */ + +import { Notice, TFile, type App } from "obsidian"; +import { exportToPdfBytes } from "../pdfExporter"; +import { renderMarkdownToHtml } from "../htmlRenderer"; +import { ensureExportFolder } from "../exportFolder"; +import { serializeMarkdown } from "../mdSerializer"; +import { getAllSectionData } from "../../sections"; +import { getTerrainFromFile } from "../../frontmatter"; +import type HexmakerPlugin from "../../HexmakerPlugin"; + +/** + * Hex section descriptor. To add a new section to the export: + * 1. Add an entry here in the order you want it to appear + * 2. If it needs data beyond what `getAllSectionData` returns, extend + * `HexSectionContext` and pass the data in via the builder + * 3. If it's GM-only (private), set `gmOnly: true` + */ +export interface HexSectionDef { + /** Lowercase id — matches `getAllSectionData`'s key */ + id: string; + /** Display heading shown in the PDF */ + title: string; + /** Whether the section content is text (raw markdown) or a link list */ + kind: "text" | "links"; + /** True for private / GM-only sections (Hidden, Secret) */ + gmOnly?: boolean; +} + +export const HEX_SECTIONS: HexSectionDef[] = [ + { id: "description", title: "Description", kind: "text" }, + { id: "landmark", title: "Landmark", kind: "text" }, + { id: "weather", title: "Weather", kind: "text" }, + { id: "hooks & rumors", title: "Hooks & Rumors", kind: "text" }, + { id: "hidden", title: "Hidden", kind: "text", gmOnly: true }, + { id: "secret", title: "Secret", kind: "text", gmOnly: true }, + { id: "towns", title: "Towns", kind: "links" }, + { id: "dungeons", title: "Dungeons", kind: "links" }, + { id: "features", title: "Features", kind: "links" }, + { id: "quests", title: "Quests", kind: "links" }, + { id: "factions", title: "Factions", kind: "links" }, + { id: "encounters table", title: "Encounters Table", kind: "links" }, +]; + +export interface SingleHexExportOptions { + /** Filename stem (no extension). Defaults to hex file basename. */ + outputName?: string; + /** Include GM-only sections (Hidden, Secret). Default false. */ + includeGmSections?: boolean; + /** Section ids to skip explicitly. Default empty. */ + excludeSections?: Set; +} + +export async function exportHexAsPdf( + plugin: HexmakerPlugin, + hexFile: TFile, + opts: SingleHexExportOptions = {}, +): Promise { + const folder = await ensureExportFolder(plugin); + const stem = + (opts.outputName ?? hexFile.basename).trim() || hexFile.basename; + const outPath = `${folder}/${sanitiseFilename(stem)}.pdf`; + + const notice = new Notice(`Exporting ${stem}.pdf…`, 0); + try { + const md = await buildHexMarkdown(plugin, hexFile, opts); + const rendered = await renderMarkdownToHtml({ + app: plugin.app, + title: hexFile.basename, + sourcePath: hexFile.path, + markdown: md, + }); + const pdfBytes = await exportToPdfBytes(rendered, { + pageSize: "Letter", + displayHeaderFooter: true, + footerTemplate: `
/
`, + }); + await writeBinaryToVault(plugin.app, outPath, pdfBytes); + new Notice(`Exported to ${outPath}`); + void openInVault(plugin.app, outPath); + } catch (err) { + console.error(err); + new Notice(`Export failed: ${(err as Error).message ?? err}`); + } finally { + notice.hide(); + } +} + +export async function exportHexAsMarkdown( + plugin: HexmakerPlugin, + hexFile: TFile, + opts: SingleHexExportOptions = {}, +): Promise { + const folder = await ensureExportFolder(plugin); + const stem = + (opts.outputName ?? hexFile.basename).trim() || hexFile.basename; + const outPath = `${folder}/${sanitiseFilename(stem)}.md`; + try { + const md = await buildHexMarkdown(plugin, hexFile, opts); + const finalMd = serializeMarkdown(md, { wikilinks: "preserve" }); + await writeTextToVault(plugin.app, outPath, finalMd); + new Notice(`Exported to ${outPath}`); + void openInVault(plugin.app, outPath); + } catch (err) { + console.error(err); + new Notice(`Export failed: ${(err as Error).message ?? err}`); + } +} + +/** + * Build the markdown document for a hex export. Walks HEX_SECTIONS in order, + * including each section that has content (and isn't gated by `gmOnly` / + * `excludeSections`). Exported for unit tests. + */ +export async function buildHexMarkdown( + plugin: HexmakerPlugin, + hexFile: TFile, + opts: SingleHexExportOptions = {}, +): Promise { + const app = plugin.app; + const lines: string[] = []; + + // Title — uses the file basename so notes with custom names show that name, + // and "0_0" style notes fall back to their coord pattern. + lines.push(`# ${hexFile.basename}`); + lines.push(""); + + // Metadata header: coord (parsed from basename) + terrain (frontmatter). + // Renders as a `**key:** value` block so it's visually distinct from + // section bodies. Markdown's two-space line-break (` \n`) keeps the + // entries on consecutive lines. + const meta = collectMetadata(app, hexFile); + if (meta.length > 0) { + lines.push(meta.map((m) => `**${m.label}:** ${m.value}`).join(" \n")); + lines.push(""); + } + + // Section bodies + const data = await getAllSectionData(app, hexFile.path); + for (const section of HEX_SECTIONS) { + if (section.gmOnly && !opts.includeGmSections) continue; + if (opts.excludeSections?.has(section.id)) continue; + const block = renderSectionBlock(section, data); + if (block) lines.push(block); + } + return lines.join("\n"); +} + +interface MetadataItem { + label: string; + value: string; +} + +function collectMetadata(app: App, hexFile: TFile): MetadataItem[] { + const items: MetadataItem[] = []; + const coord = parseHexCoord(hexFile.basename); + if (coord) items.push({ label: "Coordinate", value: `${coord.x}, ${coord.y}` }); + const terrain = getTerrainFromFile(app, hexFile.path); + if (terrain) items.push({ label: "Terrain", value: terrain }); + return items; +} + +/** + * Render one HEX_SECTIONS entry as a markdown block, or return null when the + * section has no content. Pure. + */ +export function renderSectionBlock( + section: HexSectionDef, + data: { text: Map; links: Map }, +): string | null { + if (section.kind === "text") { + const text = (data.text.get(section.id) ?? "").trim(); + if (!text) return null; + return wrapInSection(section.title, text); + } + const links = data.links.get(section.id) ?? []; + if (links.length === 0) return null; + const body = links.map((l) => `- [[${l}]]`).join("\n"); + return wrapInSection(section.title, body); +} + +/** + * Wrap a heading + body in a `
` block. + * Same approach as the linked-notes appendix: the wrapping div lets the print + * CSS apply break-inside: avoid-page + visible separation between sections. + */ +function wrapInSection(title: string, body: string): string { + return ( + `
\n\n` + + `## ${title}\n\n` + + `${body}\n\n` + + `
\n` + ); +} + +/** + * Parse a hex coord from a filename like "5_-3" → {x: 5, y: -3}. Returns + * null for any other shape (custom-named hex notes, sub-named files, …). + */ +export function parseHexCoord( + basename: string, +): { x: number; y: number } | null { + const m = /^(-?\d+)_(-?\d+)$/.exec(basename); + if (!m) return null; + return { x: parseInt(m[1], 10), y: parseInt(m[2], 10) }; +} + +// ── File-IO helpers ────────────────────────────────────────────────────── + +function sanitiseFilename(name: string): string { + return name.replace(/[\\/:*?"<>|]/g, "_"); +} + +async function writeBinaryToVault( + app: App, + path: string, + data: Uint8Array, +): Promise { + const buf = data.buffer.slice( + data.byteOffset, + data.byteOffset + data.byteLength, + ) as ArrayBuffer; + const existing = app.vault.getAbstractFileByPath(path); + if (existing instanceof TFile) { + await app.vault.modifyBinary(existing, buf); + } else { + await app.vault.createBinary(path, buf); + } +} + +async function writeTextToVault( + app: App, + path: string, + data: string, +): Promise { + const existing = app.vault.getAbstractFileByPath(path); + if (existing instanceof TFile) { + await app.vault.modify(existing, data); + } else { + await app.vault.create(path, data); + } +} + +async function openInVault(app: App, path: string): Promise { + const file = app.vault.getAbstractFileByPath(path); + if (file instanceof TFile) { + await app.workspace.getLeaf(false).openFile(file); + } +} diff --git a/src/export/exporters/singleNote.ts b/src/export/exporters/singleNote.ts new file mode 100644 index 0000000..cb1e012 --- /dev/null +++ b/src/export/exporters/singleNote.ts @@ -0,0 +1,132 @@ +/** + * Single-note exporter (Phase 1, item 1.2). + * + * Targets any markdown note in the vault. Same code path for towns, dungeons, + * quests, features, factions, hex notes — they're all just `.md` files. + * + * The export pipeline: + * 1. Read the file + * 2. Strip frontmatter (metadata, not content) + * 3. Prepend `# {basename}` if the note has no leading H1 (so every export + * has a styled title at the top) + * 4. PDF path: renderMarkdownToHtml → exportToPdfBytes → vault.createBinary + * 5. MD path: serializeMarkdown (wikilink mode) → vault.create + */ + +import { Notice, TFile, type App } from "obsidian"; +import { exportToPdfBytes } from "../pdfExporter"; +import { renderMarkdownToHtml } from "../htmlRenderer"; +import { ensureExportFolder } from "../exportFolder"; +import { serializeMarkdown, stripFrontmatter } from "../mdSerializer"; +import type HexmakerPlugin from "../../HexmakerPlugin"; + +export async function exportSingleNoteAsPdf( + plugin: HexmakerPlugin, + file: TFile, +): Promise { + const md = await buildSingleNoteMarkdown(plugin.app, file); + const folder = await ensureExportFolder(plugin); + const outPath = `${folder}/${file.basename}.pdf`; + + const notice = new Notice(`Exporting ${file.basename}.pdf…`, 0); + try { + const rendered = await renderMarkdownToHtml({ + app: plugin.app, + title: file.basename, + sourcePath: file.path, + markdown: md, + }); + const bytes = await exportToPdfBytes(rendered, { + pageSize: "Letter", + displayHeaderFooter: true, + footerTemplate: `
/
`, + }); + await writeBinaryToVault(plugin.app, outPath, bytes); + new Notice(`Exported to ${outPath}`); + void openInVault(plugin.app, outPath); + } catch (err) { + console.error(err); + new Notice(`Export failed: ${(err as Error).message ?? err}`); + } finally { + notice.hide(); + } +} + +export async function exportSingleNoteAsMarkdown( + plugin: HexmakerPlugin, + file: TFile, +): Promise { + const md = await buildSingleNoteMarkdown(plugin.app, file); + const folder = await ensureExportFolder(plugin); + const outPath = `${folder}/${file.basename}.md`; + + try { + const finalMd = serializeMarkdown(md, { wikilinks: "preserve" }); + await writeTextToVault(plugin.app, outPath, finalMd); + new Notice(`Exported to ${outPath}`); + void openInVault(plugin.app, outPath); + } catch (err) { + console.error(err); + new Notice(`Export failed: ${(err as Error).message ?? err}`); + } +} + +/** + * Read the note, strip frontmatter, and prepend a title if missing. Exported + * indirectly via `ensureLeadingTitle` for test coverage. + */ +async function buildSingleNoteMarkdown(app: App, file: TFile): Promise { + const raw = await app.vault.read(file); + return ensureLeadingTitle(raw, file.basename); +} + +/** + * Strip frontmatter and ensure the note begins with an `# H1` title. If the + * content already starts with an H1, keep it as-is. Otherwise prepend + * `# {basename}` so every exported note gets a styled title. + * + * Pure — exported for unit tests. + */ +export function ensureLeadingTitle(raw: string, basename: string): string { + const stripped = stripFrontmatter(raw).replace(/^\s+/, ""); + if (/^# /.test(stripped)) return stripped; + if (stripped.length === 0) return `# ${basename}`; + return `# ${basename}\n\n${stripped}`; +} + +async function writeBinaryToVault( + app: App, + path: string, + data: Uint8Array, +): Promise { + const buf = data.buffer.slice( + data.byteOffset, + data.byteOffset + data.byteLength, + ) as ArrayBuffer; + const existing = app.vault.getAbstractFileByPath(path); + if (existing instanceof TFile) { + await app.vault.modifyBinary(existing, buf); + } else { + await app.vault.createBinary(path, buf); + } +} + +async function writeTextToVault( + app: App, + path: string, + data: string, +): Promise { + const existing = app.vault.getAbstractFileByPath(path); + if (existing instanceof TFile) { + await app.vault.modify(existing, data); + } else { + await app.vault.create(path, data); + } +} + +async function openInVault(app: App, path: string): Promise { + const file = app.vault.getAbstractFileByPath(path); + if (file instanceof TFile) { + await app.workspace.getLeaf(false).openFile(file); + } +} diff --git a/src/export/exporters/workflow.ts b/src/export/exporters/workflow.ts new file mode 100644 index 0000000..ee0f9c5 --- /dev/null +++ b/src/export/exporters/workflow.ts @@ -0,0 +1,354 @@ +/** + * Workflow exporter (Phase 1, item 1.6). + * + * Produces a PDF showing the workflow definition (steps table + template) + * plus N rolled sample outputs. Reuses the existing roll logic from + * `random-tables/workflow.ts` and `random-tables/randomTable.ts`. + * + * Structure: + * # {workflow name} + * {description if any} + * ## Steps — table of (#, Table/Formula, Rolls, Label) + * ## Template — fenced code block of the raw template + * ## Sample outputs — N
blocks, each with the rolled values + * breakdown table + the template filled with those values + */ + +import { Notice, TFile, type App } from "obsidian"; +import { exportToPdfBytes } from "../pdfExporter"; +import { renderMarkdownToHtml } from "../htmlRenderer"; +import { ensureExportFolder } from "../exportFolder"; +import { stripFrontmatter, serializeMarkdown } from "../mdSerializer"; +import { + parseWorkflow, + stepPlaceholder, + rollDiceFormulaWithBreakdown, + type Workflow, + type WorkflowStep, +} from "../../random-tables/workflow"; +import { + parseRandomTable, + rollOnTable, +} from "../../random-tables/randomTable"; +import { normalizeFolder } from "../../utils"; +import type HexmakerPlugin from "../../HexmakerPlugin"; + +export interface WorkflowExportOptions { + /** Filename stem (no extension). Defaults to workflow basename. */ + outputName?: string; + /** Number of sample outputs to roll. Default 5. */ + numSamples?: number; + /** Include the raw template content. Default true. */ + includeTemplate?: boolean; + /** Include the per-sample rolls breakdown table. Default true. */ + includeRollsBreakdown?: boolean; +} + +export async function exportWorkflowAsPdf( + plugin: HexmakerPlugin, + workflowFile: TFile, + opts: WorkflowExportOptions = {}, +): Promise { + const folder = await ensureExportFolder(plugin); + const stem = + (opts.outputName ?? workflowFile.basename).trim() || workflowFile.basename; + const outPath = `${folder}/${sanitiseFilename(stem)}.pdf`; + + const notice = new Notice(`Exporting ${stem}.pdf…`, 0); + try { + const md = await buildWorkflowMarkdown(plugin, workflowFile, opts); + const rendered = await renderMarkdownToHtml({ + app: plugin.app, + title: workflowFile.basename, + sourcePath: workflowFile.path, + markdown: md, + }); + const pdfBytes = await exportToPdfBytes(rendered, { + pageSize: "Letter", + displayHeaderFooter: true, + footerTemplate: `
/
`, + }); + await writeBinaryToVault(plugin.app, outPath, pdfBytes); + new Notice(`Exported to ${outPath}`); + void openInVault(plugin.app, outPath); + } catch (err) { + console.error(err); + new Notice(`Export failed: ${(err as Error).message ?? err}`); + } finally { + notice.hide(); + } +} + +export async function exportWorkflowAsMarkdown( + plugin: HexmakerPlugin, + workflowFile: TFile, + opts: WorkflowExportOptions = {}, +): Promise { + const folder = await ensureExportFolder(plugin); + const stem = + (opts.outputName ?? workflowFile.basename).trim() || workflowFile.basename; + const outPath = `${folder}/${sanitiseFilename(stem)}.md`; + try { + const md = await buildWorkflowMarkdown(plugin, workflowFile, opts); + const finalMd = serializeMarkdown(md, { wikilinks: "preserve" }); + await writeTextToVault(plugin.app, outPath, finalMd); + new Notice(`Exported to ${outPath}`); + void openInVault(plugin.app, outPath); + } catch (err) { + console.error(err); + new Notice(`Export failed: ${(err as Error).message ?? err}`); + } +} + +// ── Markdown builder ────────────────────────────────────────────────────── + +/** + * Build the workflow PDF markdown. Reads the workflow + template files from + * the vault, rolls the requested number of samples, and assembles the full + * document. Exported for integration tests. + */ +export async function buildWorkflowMarkdown( + plugin: HexmakerPlugin, + workflowFile: TFile, + opts: WorkflowExportOptions = {}, +): Promise { + const numSamples = Math.max(0, opts.numSamples ?? 5); + const includeTemplate = opts.includeTemplate ?? true; + const includeBreakdown = opts.includeRollsBreakdown ?? true; + + const raw = await plugin.app.vault.read(workflowFile); + const workflow = parseWorkflow(raw, workflowFile.basename); + const template = await loadTemplate(plugin, workflow, workflowFile); + + const lines: string[] = []; + + lines.push(`# ${workflowFile.basename}`); + lines.push(""); + if (workflow.description) { + lines.push(workflow.description.trim()); + lines.push(""); + } + + // ── Steps table ────────────────────────────────────────────────────── + lines.push(`## Steps`); + lines.push(""); + lines.push(`| # | Table / Formula | Rolls | Label |`); + lines.push(`| --- | --- | --- | --- |`); + workflow.steps.forEach((step, i) => { + const tableCell = + step.kind === "dice" ? (step.diceFormula ?? "") : step.tablePath; + lines.push( + `| ${i + 1} | ${escapePipes(tableCell)} | ${step.rolls} | ${escapePipes(step.label ?? "")} |`, + ); + }); + lines.push(""); + + // ── Template ───────────────────────────────────────────────────────── + if (includeTemplate && template) { + lines.push(`## Template`); + lines.push(""); + lines.push("```"); + lines.push(stripFrontmatter(template).trim()); + lines.push("```"); + lines.push(""); + } + + // ── Samples ────────────────────────────────────────────────────────── + if (numSamples > 0 && workflow.steps.length > 0) { + lines.push(`## Sample outputs`); + lines.push(""); + for (let i = 0; i < numSamples; i++) { + const sample = await rollWorkflowSample(plugin, workflow, workflowFile); + lines.push(`
`); + lines.push(""); + lines.push(`### Sample ${i + 1}`); + lines.push(""); + if (includeBreakdown) { + lines.push(...formatRollsBreakdown(workflow, sample.rolls)); + } + const filled = fillTemplate(template, workflow, sample.rolls); + if (filled.trim()) { + lines.push(""); + lines.push(filled.trim()); + } + lines.push(""); + lines.push(`
`); + lines.push(""); + } + } + + return lines.join("\n"); +} + +// ── Sample generation ──────────────────────────────────────────────────── + +interface WorkflowSample { + /** rolls[stepIdx][rollIdx] = rolled value (string) */ + rolls: string[][]; +} + +async function rollWorkflowSample( + plugin: HexmakerPlugin, + workflow: Workflow, + workflowFile: TFile, +): Promise { + const rolls: string[][] = []; + for (const step of workflow.steps) { + const stepRolls: string[] = []; + for (let r = 0; r < step.rolls; r++) { + stepRolls.push(await rollSingleStep(plugin, step, workflowFile)); + } + rolls.push(stepRolls); + } + return { rolls }; +} + +async function rollSingleStep( + plugin: HexmakerPlugin, + step: WorkflowStep, + workflowFile: TFile, +): Promise { + if (step.kind === "dice") { + return rollDiceFormulaWithBreakdown(step.diceFormula ?? "1d6"); + } + const tableFile = + plugin.app.vault.getAbstractFileByPath(step.tablePath + ".md") ?? + plugin.app.metadataCache.getFirstLinkpathDest( + step.tablePath, + workflowFile.path, + ); + if (!(tableFile instanceof TFile)) { + return `[missing: ${step.tablePath}]`; + } + const content = await plugin.app.vault.read(tableFile); + const table = parseRandomTable(content); + const entry = rollOnTable(table); + if (!entry) return `[empty table: ${step.tablePath}]`; + return entry.isLink + ? (entry.result.split("/").pop() ?? entry.result) + : entry.result; +} + +/** Resolve and read the workflow's template file, if specified. */ +async function loadTemplate( + plugin: HexmakerPlugin, + workflow: Workflow, + workflowFile: TFile, +): Promise { + if (!workflow.templateFile) return ""; + // Template may be stored as a vault-relative path (with or without .md) or + // an Obsidian link target — try both. + const tf = + plugin.app.vault.getAbstractFileByPath(workflow.templateFile + ".md") ?? + plugin.app.vault.getAbstractFileByPath(workflow.templateFile) ?? + plugin.app.metadataCache.getFirstLinkpathDest( + workflow.templateFile, + workflowFile.path, + ); + if (!(tf instanceof TFile)) return ""; + return await plugin.app.vault.read(tf); +} + +// ── Pure helpers (exported for tests) ──────────────────────────────────── + +/** + * Substitute placeholders in a template with rolled values. Same algorithm + * as `WorkflowWizardModal.assembleResult` but with no DOM dependencies. Pure. + */ +export function fillTemplate( + template: string, + workflow: Workflow, + rolls: string[][], +): string { + if (!template) return ""; + let result = template; + for (let si = 0; si < workflow.steps.length; si++) { + const step = workflow.steps[si]; + const stepRolls = rolls[si] ?? []; + for (let ri = 0; ri < step.rolls; ri++) { + const placeholder = stepPlaceholder(step, ri); + const value = stepRolls[ri] ?? `[${placeholder}]`; + const escaped = placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + result = result.replace(new RegExp(escaped, "g"), value); + } + } + return result; +} + +/** + * Format a per-sample rolls breakdown as a small markdown table. Pure. + * Returns an array of markdown lines (no trailing blank line). + */ +export function formatRollsBreakdown( + workflow: Workflow, + rolls: string[][], +): string[] { + if (workflow.steps.length === 0) return []; + const out: string[] = []; + out.push(`| Step | Label | Roll | Value |`); + out.push(`| --- | --- | --- | --- |`); + workflow.steps.forEach((step, si) => { + const label = + step.label ?? + (step.kind === "dice" + ? (step.diceFormula ?? "") + : (step.tablePath.split("/").pop() ?? step.tablePath)); + for (let ri = 0; ri < step.rolls; ri++) { + const value = rolls[si]?.[ri] ?? ""; + const rollDisplay = step.rolls === 1 ? "—" : String(ri + 1); + out.push( + `| ${si + 1} | ${escapePipes(label)} | ${rollDisplay} | ${escapePipes(value)} |`, + ); + } + }); + return out; +} + +function escapePipes(s: string): string { + return s.replace(/\|/g, "\\|"); +} + +function sanitiseFilename(name: string): string { + return name.replace(/[\\/:*?"<>|]/g, "_"); +} + +async function writeBinaryToVault( + app: App, + path: string, + data: Uint8Array, +): Promise { + const buf = data.buffer.slice( + data.byteOffset, + data.byteOffset + data.byteLength, + ) as ArrayBuffer; + const existing = app.vault.getAbstractFileByPath(path); + if (existing instanceof TFile) { + await app.vault.modifyBinary(existing, buf); + } else { + await app.vault.createBinary(path, buf); + } +} + +async function writeTextToVault( + app: App, + path: string, + data: string, +): Promise { + const existing = app.vault.getAbstractFileByPath(path); + if (existing instanceof TFile) { + await app.vault.modify(existing, data); + } else { + await app.vault.create(path, data); + } +} + +async function openInVault(app: App, path: string): Promise { + const file = app.vault.getAbstractFileByPath(path); + if (file instanceof TFile) { + await app.workspace.getLeaf(false).openFile(file); + } +} + +// `normalizeFolder` is imported even though we don't use it yet — keeping the +// import keeps the file aligned with other exporters and ready to expand. +void normalizeFolder; diff --git a/src/export/htmlRenderer.ts b/src/export/htmlRenderer.ts new file mode 100644 index 0000000..ef4c230 --- /dev/null +++ b/src/export/htmlRenderer.ts @@ -0,0 +1,75 @@ +/** + * HTML rendering primitive — markdown to a {@link RenderedDoc} ready for printToPDF. + * + * Uses Obsidian's own `MarkdownRenderer.render()` so the export matches the + * user's installed theme. CSS is captured by walking `document.styleSheets`. + */ + +import { App, Component, MarkdownRenderer } from "obsidian"; +import type { RenderedDoc } from "./pdfExporter"; +import { wrapInPrintScaffold, PRINT_PATCH_CSS } from "./printScaffold"; + +export interface RenderOptions { + app: App; + /** Document title (used in and the printed page header). */ + title: string; + /** Vault-relative source path — passed to Obsidian for wikilink resolution. */ + sourcePath: string; + /** Markdown content to render. */ + markdown: string; +} + +/** + * Render markdown to a {@link RenderedDoc}. Captures the user's theme CSS so + * the PDF matches their installed Obsidian theme. + */ +export async function renderMarkdownToHtml( + opts: RenderOptions, +): Promise<RenderedDoc> { + const { app, title, sourcePath, markdown } = opts; + + // Off-screen container so MarkdownRenderer.render has a real DOM target. + const container = document.body.createDiv({ + cls: "duckmage-export-render-host markdown-preview-view markdown-rendered", + }); + const component = new Component(); + component.load(); + + try { + await MarkdownRenderer.render(app, markdown, container, sourcePath, component); + + const bodyHtml = wrapInPrintScaffold(title, container.innerHTML); + const css = captureStyles() + "\n" + PRINT_PATCH_CSS; + + return { bodyHtml, css, title }; + } finally { + component.unload(); + container.remove(); + } +} + +/** + * Walk `document.styleSheets` and concatenate all readable CSS rules. + * Skips Svelte-injected sheets (transient, irrelevant) and silently skips + * sheets that throw on cssRules access (CORS). + */ +function captureStyles(): string { + const parts: string[] = []; + for (const sheet of Array.from(document.styleSheets)) { + const node = sheet.ownerNode as Element | null; + const id = node?.getAttribute("id") ?? ""; + if (id.startsWith("svelte-")) continue; + + try { + const rules = sheet.cssRules; + if (!rules) continue; + for (const rule of Array.from(rules)) { + parts.push(rule.cssText); + } + } catch { + // CORS or other access failure — skip this sheet. + } + } + return parts.join("\n"); +} + diff --git a/src/export/mapPngRenderer.ts b/src/export/mapPngRenderer.ts new file mode 100644 index 0000000..9adb23b --- /dev/null +++ b/src/export/mapPngRenderer.ts @@ -0,0 +1,751 @@ +/** + * Hex-map PNG renderer (Phase 1, item 1.3). + * + * Independent of HexMapView's DOM rendering — re-implements the layout in + * canvas so the output is a clean raster at user-chosen resolution, ignoring + * pan/zoom and unaffected by CSS layout quirks. Geometry is shared with the + * overlay code via `hex-map/hexGeometry.ts`. + * + * Pipeline: + * for each (col, row) → resolve hex note → terrain + optional icon → + * draw hex polygon + fill + border + icon + optional coord label → + * then draw all path chains via the same routing helpers HexMapView uses. + */ + +import { Notice, TFile, type App } from "obsidian"; +import { + hexCenter, + hexPolygonPoints, + buildEdgePts, + buildMeanderPts, + smoothPath, + sharpPath, +} from "../hex-map/hexGeometry"; +import { ensureExportFolder } from "./exportFolder"; +import { + getTerrainFromFile, + getIconOverrideFromFile, + getFactionColorFromFile, + getRegionColorFromFile, +} from "../frontmatter"; +import { getIconUrl, normalizeFolder } from "../utils"; +import type HexmakerPlugin from "../HexmakerPlugin"; +import type { TerrainColor, MapData } from "../types"; + +export interface MapPngRenderOptions { + /** Pixel radius of each hex. Larger = higher-resolution output. Default 50. */ + hexRadius?: number; + /** Padding in pixels around the grid. Default 40. */ + padding?: number; + /** Background colour. Default dark grey. */ + background?: string; + /** Hex border colour. Default near-black. */ + borderColor?: string; + /** Coordinate label colour. Default light grey. */ + coordColor?: string; + /** Draw coordinate labels in each hex. Default true. */ + showCoords?: boolean; + /** Draw terrain icons. Default true. */ + showIcons?: boolean; + /** Include road/river/path chains. Default true. */ + showPaths?: boolean; + /** + * Restrict rendering to a sub-rectangle of the grid. When set, only hexes + * inside the half-open range `[colStart, colEnd) × [rowStart, rowEnd)` are + * drawn, and the canvas is sized to fit just those. Used by the map PDF + * exporter to render section views. + */ + subgrid?: { + colStart: number; + colEnd: number; + rowStart: number; + rowEnd: number; + }; + /** Tint hexes by their linked factions. Default false. */ + showFactionOverlay?: boolean; + /** Tint hexes by their region. Default false. */ + showRegionOverlay?: boolean; + /** + * Opacity of overlay tints, 0–1. Default 0.4. Multiple factions on the + * same hex are layered, so the effective coverage compounds. + */ + overlayOpacity?: number; +} + +/** + * Render the named map to a PNG blob. Returns the blob; caller writes it. + * Throws if the map name isn't found in settings. + */ +export async function renderMapToPngBlob( + plugin: HexmakerPlugin, + mapName: string, + opts: MapPngRenderOptions = {}, +): Promise<Blob> { + const map = plugin.settings.maps.find((m) => m.name === mapName); + if (!map) throw new Error(`Map "${mapName}" not found`); + + const R = opts.hexRadius ?? 50; + const padding = opts.padding ?? 40; + const showCoords = opts.showCoords ?? true; + const showIcons = opts.showIcons ?? true; + const showPaths = opts.showPaths ?? true; + const showFactionOverlay = opts.showFactionOverlay ?? false; + const showRegionOverlay = opts.showRegionOverlay ?? false; + const overlayOpacity = clamp(opts.overlayOpacity ?? 0.4, 0, 1); + const background = opts.background ?? "#1a1a1a"; + const borderColor = opts.borderColor ?? "#222"; + const coordColor = opts.coordColor ?? "#bbb"; + + const orientation = plugin.settings.hexOrientation; + const isFlat = orientation === "flat"; + const stagger = map.staggerOffset ?? plugin.settings.staggerOffset; + const palette = plugin.settings.terrainPalettes.find( + (p) => p.name === map.paletteName, + ); + // First-occurrence wins, matching HexMapView's `.find()` behaviour. Users + // can end up with duplicate-named entries in their palette (e.g. after + // copy-paste edits in settings); the first one is canonical. + const terrainByName = new Map<string, TerrainColor>(); + for (const t of palette?.terrains ?? []) { + if (!terrainByName.has(t.name)) terrainByName.set(t.name, t); + } + + const { cols, rows } = map.gridSize; + const { x: ox, y: oy } = map.gridOffset; + + // Clamp the subgrid range to the actual grid bounds. When unset, the range + // covers everything → equivalent to the previous behaviour. + const colStart = Math.max(0, opts.subgrid?.colStart ?? 0); + const colEnd = Math.min(cols, opts.subgrid?.colEnd ?? cols); + const rowStart = Math.max(0, opts.subgrid?.rowStart ?? 0); + const rowEnd = Math.min(rows, opts.subgrid?.rowEnd ?? rows); + + // Step 1: pre-compute centres for every cell IN RANGE. centerMap is keyed + // by the "x_y" hex coord (the same scheme path chains use). + const centerMap = new Map<string, { cx: number; cy: number }>(); + for (let row = rowStart; row < rowEnd; row++) { + for (let col = colStart; col < colEnd; col++) { + const c = hexCenter(col, row, orientation, R, stagger); + centerMap.set(`${col + ox}_${row + oy}`, c); + } + } + + // Step 2: pixel-shift all centres so the grid sits at (padding, padding). + const allCenters = Array.from(centerMap.values()); + const minX = Math.min(...allCenters.map((c) => c.cx)) - R; + const minY = Math.min(...allCenters.map((c) => c.cy)) - R; + const maxX = Math.max(...allCenters.map((c) => c.cx)) + R; + const maxY = Math.max(...allCenters.map((c) => c.cy)) + R; + const shifted = new Map<string, { cx: number; cy: number }>(); + for (const [k, c] of centerMap) { + shifted.set(k, { cx: c.cx - minX + padding, cy: c.cy - minY + padding }); + } + const W = Math.ceil(maxX - minX + padding * 2); + const H = Math.ceil(maxY - minY + padding * 2); + + // Step 3a: build colour maps for overlays (faction-basename → hex colour, + // region-name → hex colour). Empty if the respective overlay is off. + const factionColorMap = showFactionOverlay + ? buildFactionColorMap(plugin) + : new Map<string, string>(); + const regionColorMap = showRegionOverlay + ? buildRegionColorMap(plugin) + : new Map<string, string>(); + + // Step 3b: collect every hex's terrain + icon override + overlay info before + // drawing so we can pre-load icon images once each. We also track which + // factions / regions are actually present on this map so the legend at the + // end can show just those (not the entire vault folder). + interface HexState { + key: string; + cx: number; + cy: number; + terrain?: TerrainColor; + iconName?: string; + factionColors: string[]; + regionColor?: string; + } + const hexes: HexState[] = []; + const iconsNeeded = new Set<string>(); + const factionsUsed = new Map<string, string>(); // name → color + const regionsUsed = new Map<string, string>(); // name → color + // Per-region hex centres, used to position the on-map region label at the + // cluster centroid (with PCA-derived rotation). Same approach as the + // on-screen `renderRegionOverlay` in HexMapView. + const regionHexCenters = new Map<string, { cx: number; cy: number }[]>(); + for (let row = rowStart; row < rowEnd; row++) { + for (let col = colStart; col < colEnd; col++) { + const hx = col + ox; + const hy = row + oy; + const key = `${hx}_${hy}`; + const c = shifted.get(key); + if (!c) continue; + const notePath = plugin.hexPath(hx, hy, mapName); + const file = plugin.app.vault.getAbstractFileByPath(notePath); + + let terrain: TerrainColor | undefined; + let iconOverride: string | undefined; + const factionColors: string[] = []; + let regionColor: string | undefined; + if (file instanceof TFile) { + const terrainName = getTerrainFromFile(plugin.app, file.path); + if (terrainName) terrain = terrainByName.get(terrainName); + iconOverride = getIconOverrideFromFile(plugin.app, file.path) ?? undefined; + if (showFactionOverlay) { + for (const fName of getHexFactionLinks(plugin.app, file)) { + const c = factionColorMap.get(fName); + if (c) { + factionColors.push(c); + factionsUsed.set(fName, c); + } + } + } + if (showRegionOverlay) { + const regionName = getHexRegionName(plugin.app, file); + if (regionName) { + const c = regionColorMap.get(regionName); + if (c) { + regionColor = c; + regionsUsed.set(regionName, c); + const center = shifted.get(`${hx}_${hy}`); + if (center) { + if (!regionHexCenters.has(regionName)) { + regionHexCenters.set(regionName, []); + } + regionHexCenters.get(regionName)!.push(center); + } + } + } + } + } + const iconName = iconOverride ?? terrain?.icon; + if (iconName && showIcons) iconsNeeded.add(iconName); + hexes.push({ + key, + cx: c.cx, + cy: c.cy, + terrain, + iconName, + factionColors, + regionColor, + }); + } + } + + const iconCache = await loadIcons(plugin, iconsNeeded); + + // Step 4: render to canvas. OffscreenCanvas is reliable in Obsidian's + // Electron Chromium; convertToBlob() is Promise-based. + const canvas = new OffscreenCanvas(W, H); + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Could not get 2D canvas context"); + + // Background + ctx.fillStyle = background; + ctx.fillRect(0, 0, W, H); + + // Hexes — base fill + overlay tints + icons + coords + for (const hex of hexes) { + drawHex( + ctx, + hex.cx, + hex.cy, + R, + orientation, + hex.terrain?.color ?? "#2a2a2a", + borderColor, + ); + // Region overlay first (under faction): regions are larger, factions + // are usually narrower local groups, so faction tint reads on top. + if (showRegionOverlay && hex.regionColor) { + fillHexPolygon(ctx, hex.cx, hex.cy, R, orientation, hex.regionColor, overlayOpacity); + } + if (showFactionOverlay) { + for (const color of hex.factionColors) { + fillHexPolygon(ctx, hex.cx, hex.cy, R, orientation, color, overlayOpacity); + } + } + if (showIcons && hex.iconName) { + const img = iconCache.get(hex.iconName); + if (img) { + const size = R * 0.9; + ctx.drawImage(img, hex.cx - size / 2, hex.cy - size / 2, size, size); + } + } + if (showCoords) { + const [hxStr, hyStr] = hex.key.split("_"); + ctx.fillStyle = coordColor; + ctx.font = `${Math.round(R * 0.22)}px sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "alphabetic"; + ctx.fillText(`${hxStr},${hyStr}`, hex.cx, hex.cy + R * 0.75); + } + } + + // Region labels — drawn over the map at each region's centroid, rotated + // along the cluster's principal axis. Same algorithm as the on-screen + // `renderRegionOverlay`. + if (showRegionOverlay && regionHexCenters.size > 0) { + for (const [regionName, centers] of regionHexCenters) { + drawRegionLabel(ctx, regionName, centers, R); + } + } + + // Faction key — a labelled swatch panel at the bottom-left. Only lists + // factions actually used on this map. + if (showFactionOverlay && factionsUsed.size > 0) { + const fontSize = Math.max(11, Math.round(R * 0.28)); + const legendPadding = 16; + const size = drawLegend( + ctx, + padding, + 0, + "Factions", + sortedEntries(factionsUsed), + fontSize, + legendPadding, + true, // measure pass first to anchor from the bottom + ); + drawLegend( + ctx, + padding, + H - padding - size.height, + "Factions", + sortedEntries(factionsUsed), + fontSize, + legendPadding, + false, + ); + } + + // Paths + if (showPaths) { + for (const pt of plugin.settings.pathTypes) { + const dash = dashFor(pt.lineStyle); + const chains = map.pathChains.filter((c) => c.typeName === pt.name); + for (const chain of chains) { + let pts; + let smooth: boolean; + if (pt.routing === "edge") { + pts = buildEdgePts(chain.hexes, shifted, isFlat, R); + smooth = false; + } else if (pt.routing === "meander") { + pts = buildMeanderPts(chain.hexes, shifted); + smooth = true; + } else { + pts = chain.hexes + .map((k) => shifted.get(k)) + .filter((p): p is { cx: number; cy: number } => !!p); + smooth = true; + } + if (pts.length < 2) continue; + drawPath(ctx, pts, pt.color, pt.width, dash, smooth); + } + } + } + + return canvas.convertToBlob({ type: "image/png" }); +} + +// ── Drawing primitives ────────────────────────────────────────────────────── + +function drawHex( + ctx: OffscreenCanvasRenderingContext2D, + cx: number, + cy: number, + radius: number, + orientation: "flat" | "pointy", + fillColor: string, + borderColor: string, +): void { + const pts = hexPolygonPoints(cx, cy, orientation, radius); + ctx.beginPath(); + ctx.moveTo(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y); + ctx.closePath(); + ctx.fillStyle = fillColor; + ctx.fill(); + ctx.lineWidth = Math.max(1, radius * 0.04); + ctx.strokeStyle = borderColor; + ctx.stroke(); +} + +function drawPath( + ctx: OffscreenCanvasRenderingContext2D, + pts: { cx: number; cy: number }[], + color: string, + width: number, + dash: number[], + smooth: boolean, +): void { + // Re-use the SVG-style path string from hexGeometry; Path2D parses the + // same `M / L / Q` commands so we don't need to duplicate path math. + const d = smooth ? smoothPath(pts) : sharpPath(pts); + const path = new Path2D(d); + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + if (dash.length > 0) ctx.setLineDash(dash); + else ctx.setLineDash([]); + ctx.stroke(path); +} + +function clamp(v: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, v)); +} + +/** + * Draw a region's name label centred over its hex cluster, rotated to match + * the cluster's principal axis. Ports the algorithm from + * `HexMapView.renderRegionOverlay`: + * + * - Centroid = mean of hex centres + * - Angle = `atan2(2*sxy, sxx - syy) / 2` (PCA on covariance matrix) + * - Font size scales with √(hexCount), then again with hexRadius/40 so the + * label keeps its visual prominence at any export resolution + * - White text with a dark stroke for legibility on any base colour + */ +function drawRegionLabel( + ctx: OffscreenCanvasRenderingContext2D, + name: string, + centers: Array<{ cx: number; cy: number }>, + hexRadius: number, +): void { + const n = centers.length; + if (n === 0) return; + + const mx = centers.reduce((s, p) => s + p.cx, 0) / n; + const my = centers.reduce((s, p) => s + p.cy, 0) / n; + + let angleDeg = 0; + if (n > 1) { + const sxx = centers.reduce((s, p) => s + (p.cx - mx) ** 2, 0); + const syy = centers.reduce((s, p) => s + (p.cy - my) ** 2, 0); + const sxy = centers.reduce((s, p) => s + (p.cx - mx) * (p.cy - my), 0); + let ang = (Math.atan2(2 * sxy, sxx - syy) * 180) / Math.PI / 2; + if (ang > 90) ang -= 180; + if (ang < -90) ang += 180; + angleDeg = ang; + } + + const baseFont = Math.min(10 + 3 * Math.sqrt(n), 52); + // Scale font with hexRadius so labels keep their on-screen weight when + // exporting at high resolutions. R=40 matches the on-screen calibration. + const fontSize = Math.max(12, Math.round(baseFont * (hexRadius / 40))); + + ctx.save(); + ctx.translate(mx, my); + ctx.rotate((angleDeg * Math.PI) / 180); + ctx.font = `bold ${fontSize}px sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + // Dark outline → white fill = readable across light and dark backgrounds. + ctx.lineJoin = "round"; + ctx.lineWidth = Math.max(2, fontSize * 0.18); + ctx.strokeStyle = "rgba(0, 0, 0, 0.7)"; + ctx.strokeText(name, 0, 0); + ctx.fillStyle = "#ffffff"; + ctx.fillText(name, 0, 0); + + ctx.restore(); +} + +function sortedEntries(m: Map<string, string>): Array<[string, string]> { + return Array.from(m.entries()).sort((a, b) => + a[0].localeCompare(b[0], undefined, { sensitivity: "base" }), + ); +} + +/** + * Draw (or measure) a legend box. When `measureOnly` is true, no pixels are + * touched and only the bounding-box size is returned — useful for laying out + * multiple stacked legends bottom-up. + */ +function drawLegend( + ctx: OffscreenCanvasRenderingContext2D, + x: number, + y: number, + title: string, + entries: Array<[string, string]>, + fontSize: number, + pad: number, + measureOnly: boolean, +): { width: number; height: number } { + if (entries.length === 0) return { width: 0, height: 0 }; + + const lineHeight = Math.round(fontSize * 1.45); + const swatch = Math.round(fontSize * 1.1); + const gap = Math.round(fontSize * 0.5); + + // Measure widths. We need to set the font for accurate measurements. + ctx.save(); + ctx.font = `bold ${fontSize}px sans-serif`; + const titleWidth = ctx.measureText(title).width; + ctx.font = `${fontSize}px sans-serif`; + let widestEntry = 0; + for (const [name] of entries) { + const w = ctx.measureText(name).width; + if (w > widestEntry) widestEntry = w; + } + ctx.restore(); + + const contentWidth = Math.ceil( + Math.max(titleWidth, swatch + gap + widestEntry), + ); + const width = contentWidth + pad * 2; + // Title + entries, each on its own line. + const height = pad * 2 + lineHeight + lineHeight * entries.length; + + if (measureOnly) return { width, height }; + + // Background panel — readable on both dark terrain and bright overlays. + ctx.save(); + ctx.fillStyle = "rgba(20, 20, 20, 0.86)"; + ctx.fillRect(x, y, width, height); + ctx.strokeStyle = "#555"; + ctx.lineWidth = 1; + ctx.strokeRect(x + 0.5, y + 0.5, width - 1, height - 1); + + ctx.textBaseline = "top"; + ctx.textAlign = "left"; + + // Title row. + ctx.font = `bold ${fontSize}px sans-serif`; + ctx.fillStyle = "#fff"; + ctx.fillText(title, x + pad, y + pad); + + // Entry rows. + ctx.font = `${fontSize}px sans-serif`; + let entryY = y + pad + lineHeight; + for (const [name, color] of entries) { + // Swatch — vertically centred on the text baseline. + const swatchY = entryY + Math.round((lineHeight - swatch) / 2); + ctx.fillStyle = color; + ctx.fillRect(x + pad, swatchY, swatch, swatch); + ctx.strokeStyle = "#888"; + ctx.strokeRect(x + pad + 0.5, swatchY + 0.5, swatch - 1, swatch - 1); + + ctx.fillStyle = "#fff"; + ctx.fillText(name, x + pad + swatch + gap, entryY); + entryY += lineHeight; + } + ctx.restore(); + + return { width, height }; +} + +/** + * Fill a hex polygon with a colour at the given opacity. Used for overlay + * tints; doesn't stroke a border (the base hex's border is already drawn). + */ +function fillHexPolygon( + ctx: OffscreenCanvasRenderingContext2D, + cx: number, + cy: number, + radius: number, + orientation: "flat" | "pointy", + color: string, + opacity: number, +): void { + const pts = hexPolygonPoints(cx, cy, orientation, radius); + ctx.beginPath(); + ctx.moveTo(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y); + ctx.closePath(); + ctx.fillStyle = color; + const prev = ctx.globalAlpha; + ctx.globalAlpha = opacity; + ctx.fill(); + ctx.globalAlpha = prev; +} + +// ── Overlay helpers ──────────────────────────────────────────────────────── + +/** + * Walk every markdown file in the factions folder, read its `faction-color` + * frontmatter, return a map of basename → colour. + */ +function buildFactionColorMap(plugin: HexmakerPlugin): Map<string, string> { + const out = new Map<string, string>(); + const folder = normalizeFolder(plugin.settings.factionsFolder ?? ""); + for (const f of plugin.app.vault.getMarkdownFiles()) { + if (folder && !f.path.startsWith(folder + "/")) continue; + if (f.basename.startsWith("_")) continue; + const color = getFactionColorFromFile(plugin.app, f.path); + if (color) out.set(f.basename, color); + } + return out; +} + +/** + * Walk every markdown file in the regions folder, read its `region-color` + * frontmatter, return a map of basename → colour. + */ +function buildRegionColorMap(plugin: HexmakerPlugin): Map<string, string> { + const out = new Map<string, string>(); + const folder = normalizeFolder(plugin.settings.regionsFolder ?? ""); + for (const f of plugin.app.vault.getMarkdownFiles()) { + if (folder && !f.path.startsWith(folder + "/")) continue; + if (f.basename.startsWith("_")) continue; + const color = getRegionColorFromFile(plugin.app, f.path); + if (color) out.set(f.basename, color); + } + return out; +} + +/** + * Read the wiki-link basenames listed under `### Factions` in a hex note, + * using the metadata cache directly (no DOM dependency). + */ +function getHexFactionLinks(app: App, file: TFile): string[] { + const cache = app.metadataCache.getFileCache(file); + if (!cache) return []; + const headings = cache.headings ?? []; + const factionHeading = headings.find( + (h) => h.heading === "Factions" && h.level === 3, + ); + if (!factionHeading) return []; + const factionStart = factionHeading.position.start.offset; + const nextHeading = headings.find( + (h) => h.position.start.offset > factionStart && h.level <= 3, + ); + const factionEnd = nextHeading?.position.start.offset ?? Infinity; + return (cache.links ?? []) + .filter( + (lk) => + lk.position.start.offset > factionStart && + lk.position.start.offset < factionEnd, + ) + .map((lk) => { + const target = lk.link.split("|")[0].split("#")[0].trim(); + const slash = target.lastIndexOf("/"); + const stem = slash >= 0 ? target.slice(slash + 1) : target; + return stem.replace(/\.md$/i, ""); + }); +} + +/** Read the `region:` frontmatter of a hex note via metadata cache. */ +function getHexRegionName(app: App, file: TFile): string | null { + const cache = app.metadataCache.getFileCache(file); + const region: unknown = cache?.frontmatter?.["region"]; + return typeof region === "string" ? region : null; +} + +function dashFor(style: "solid" | "dashed" | "dotted"): number[] { + switch (style) { + case "dashed": + return [12, 8]; + case "dotted": + return [2, 6]; + default: + return []; + } +} + +// ── Icon loading ─────────────────────────────────────────────────────────── + +async function loadIcons( + plugin: HexmakerPlugin, + names: Set<string>, +): Promise<Map<string, HTMLImageElement>> { + const cache = new Map<string, HTMLImageElement>(); + const tasks: Promise<void>[] = []; + for (const name of names) { + const url = getIconUrl(plugin, name); + tasks.push( + loadImage(url) + .then((img) => { + cache.set(name, img); + }) + .catch(() => { + console.warn(`Map export: failed to load icon ${name}`); + }), + ); + } + await Promise.all(tasks); + return cache; +} + +function loadImage(url: string): Promise<HTMLImageElement> { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = url; + }); +} + +// ── Public exporter (used by the MapModal Export tab) ────────────────────── + +/** + * Options for the `exportMapAsPng` wrapper. Extends pure rendering options + * with file-naming concerns the wrapper layer cares about. + */ +export interface MapPngExportOptions extends MapPngRenderOptions { + /** + * Filename stem to write to the export folder (no extension). Defaults to + * `mapName`. Sanitised to strip vault-illegal characters. + */ + outputName?: string; +} + +export async function exportMapAsPng( + plugin: HexmakerPlugin, + mapName: string, + opts: MapPngExportOptions = {}, +): Promise<void> { + const folder = await ensureExportFolder(plugin); + const stem = (opts.outputName ?? mapName).trim() || mapName; + const outPath = `${folder}/${sanitiseFilename(stem)}.png`; + + const notice = new Notice(`Exporting ${mapName}.png…`, 0); + try { + const blob = await renderMapToPngBlob(plugin, mapName, opts); + const buf = new Uint8Array(await blob.arrayBuffer()); + await writeBinaryToVault(plugin, outPath, buf); + new Notice(`Exported to ${outPath}`); + void openInVault(plugin, outPath); + } catch (err) { + console.error(err); + new Notice(`Map export failed: ${(err as Error).message ?? err}`); + } finally { + notice.hide(); + } +} + +function sanitiseFilename(name: string): string { + // Strip vault-illegal characters from the map name when used as a filename. + return name.replace(/[\\/:*?"<>|]/g, "_"); +} + +async function writeBinaryToVault( + plugin: HexmakerPlugin, + path: string, + data: Uint8Array, +): Promise<void> { + const buf = data.buffer.slice( + data.byteOffset, + data.byteOffset + data.byteLength, + ) as ArrayBuffer; + const existing = plugin.app.vault.getAbstractFileByPath(path); + if (existing instanceof TFile) { + await plugin.app.vault.modifyBinary(existing, buf); + } else { + await plugin.app.vault.createBinary(path, buf); + } +} + +async function openInVault( + plugin: HexmakerPlugin, + path: string, +): Promise<void> { + const file = plugin.app.vault.getAbstractFileByPath(path); + if (file instanceof TFile) { + await plugin.app.workspace.getLeaf(false).openFile(file); + } +} + +// Re-export so the renderer's surface is self-contained. +export type { MapData }; diff --git a/src/export/mdSerializer.ts b/src/export/mdSerializer.ts new file mode 100644 index 0000000..cdc1a47 --- /dev/null +++ b/src/export/mdSerializer.ts @@ -0,0 +1,66 @@ +/** + * Markdown serialization helpers for the export pipeline. + * + * For Phase 1 this is intentionally minimal — most exporters generate clean + * markdown directly from structured data, so the main job here is wikilink + * handling for the MD output path. + * + * Phase 2 (book export with folder bundle) will need real link rewriting + * (`[[Town]]` → `[text](town.md)`); save that complexity for then. + */ + +export interface MdSerializeOptions { + /** + * What to do with `[[wikilinks]]`: + * - `"preserve"` — leave as-is (default; works in Obsidian, not elsewhere) + * - `"to-bold"` — strip to bold text (e.g. `[[Foo]]` → `**Foo**`) + * - `"to-plain"` — strip to plain text (e.g. `[[Foo|Bar]]` → `Bar`) + */ + wikilinks?: "preserve" | "to-bold" | "to-plain"; +} + +export function serializeMarkdown( + raw: string, + opts: MdSerializeOptions = {}, +): string { + const mode = opts.wikilinks ?? "preserve"; + if (mode === "preserve") return raw; + return rewriteWikilinks(raw, mode); +} + +function rewriteWikilinks( + raw: string, + mode: "to-bold" | "to-plain", +): string { + // Match `[[target]]` or `[[target|alias]]`. The alias (if present) is the + // display text; otherwise the target itself is displayed (basename only). + return raw.replace( + /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, + (_match: string, target: string, alias: string | undefined): string => { + const display = (alias ?? basename(target)).trim(); + return mode === "to-bold" ? `**${display}**` : display; + }, + ); +} + +function basename(target: string): string { + const slashIdx = target.lastIndexOf("/"); + const name = slashIdx >= 0 ? target.slice(slashIdx + 1) : target; + // Strip optional .md extension + return name.replace(/\.md$/i, ""); +} + +/** + * Strip a leading YAML frontmatter block (`---\n...\n---\n`) from a markdown + * string. Returns the content untouched if no frontmatter is present. + * + * The frontmatter block must start at the very first character of the input — + * we don't search beyond the start. + */ +export function stripFrontmatter(md: string): string { + if (!md.startsWith("---")) return md; + // The body group is optional so empty frontmatter (`---\n---`) also strips. + const match = /^---\r?\n(?:[\s\S]*?\r?\n)?---\r?\n?/.exec(md); + if (!match) return md; + return md.slice(match[0].length).replace(/^\r?\n+/, ""); +} diff --git a/src/export/pdfExporter.ts b/src/export/pdfExporter.ts new file mode 100644 index 0000000..a214c58 --- /dev/null +++ b/src/export/pdfExporter.ts @@ -0,0 +1,158 @@ +/** + * PDF export primitive — wraps Electron's webview `printToPDF()`. + * + * Architecture: see knowledgebase/projects/duckmage-plugin/notes/impl/ref-better-export-pdf.md + * and plan-export-phase1.md. + * + * Strategy: create a hidden <webview>, inject the rendered HTML + CSS, force + * light theme (dark backgrounds print badly), then call printToPDF and clean up. + */ + +// Minimal local types — avoids adding `electron` as a dependency just for typings. +interface PrintToPDFOptions { + landscape?: boolean; + printBackground?: boolean; + pageSize?: string | { width: number; height: number }; + margins?: { + marginType?: "default" | "none" | "printableArea" | "custom"; + top?: number; + bottom?: number; + left?: number; + right?: number; + }; + displayHeaderFooter?: boolean; + headerTemplate?: string; + footerTemplate?: string; + scale?: number; +} + +interface WebviewElement extends HTMLElement { + printToPDF(options: PrintToPDFOptions): Promise<Uint8Array>; + executeJavaScript(code: string): Promise<unknown>; +} + +export interface PdfExportOptions { + pageSize?: "A4" | "Letter" | "Legal" | "Tabloid" | { width: number; height: number }; + landscape?: boolean; + /** Margins in inches. Omit for default. */ + margins?: { top: number; bottom: number; left: number; right: number }; + printBackground?: boolean; + displayHeaderFooter?: boolean; + headerTemplate?: string; + footerTemplate?: string; + scale?: number; +} + +export interface RenderedDoc { + /** HTML for the page <body> — does NOT include the <body> tag itself. */ + bodyHtml: string; + /** CSS rules captured from document.styleSheets, joined with newlines. */ + css: string; + /** Document title. */ + title: string; +} + +/** + * Render a {@link RenderedDoc} to a PDF byte buffer via a hidden webview. + * Caller is responsible for writing the bytes (e.g. `vault.createBinary`). + */ +export async function exportToPdfBytes( + doc: RenderedDoc, + opts: PdfExportOptions = {}, +): Promise<Uint8Array> { + const webview = document.createElement("webview") as WebviewElement; + webview.setAttribute("src", "app://obsidian.md/help.html"); + webview.setAttribute("nodeintegration", "true"); + webview.addClass("duckmage-export-webview"); + document.body.appendChild(webview); + + try { + await waitForWebviewLoad(webview); + + const injectScript = buildInjectScript(doc); + await webview.executeJavaScript(injectScript); + + // Give the webview time to lay out, load fonts, and resolve any deferred work + // (image decode, late-bound CSS). 300ms is enough for plugin-generated content. + await sleep(300); + + const printOptions: PrintToPDFOptions = { + landscape: opts.landscape ?? false, + printBackground: opts.printBackground ?? true, + pageSize: opts.pageSize ?? "Letter", + margins: opts.margins + ? { + marginType: "custom", + top: opts.margins.top, + bottom: opts.margins.bottom, + left: opts.margins.left, + right: opts.margins.right, + } + : { marginType: "default" }, + displayHeaderFooter: opts.displayHeaderFooter ?? false, + headerTemplate: opts.headerTemplate ?? "<span></span>", + footerTemplate: opts.footerTemplate ?? "<span></span>", + scale: opts.scale ?? 1, + }; + + const bytes = await webview.printToPDF(printOptions); + return bytes; + } finally { + webview.remove(); + } +} + +function waitForWebviewLoad(webview: HTMLElement): Promise<void> { + return new Promise((resolve, reject) => { + const timeoutMs = 10_000; + let settled = false; + const onLoad = () => { + if (settled) return; + settled = true; + webview.removeEventListener("did-finish-load", onLoad); + resolve(); + }; + webview.addEventListener("did-finish-load", onLoad); + setTimeout(() => { + if (settled) return; + settled = true; + webview.removeEventListener("did-finish-load", onLoad); + reject(new Error(`webview did not load within ${timeoutMs}ms`)); + }, timeoutMs); + }); +} + +function buildInjectScript(doc: RenderedDoc): string { + // Encode payloads as URI-component-encoded strings so backticks and ${} in + // content can't break out of the template literal we're constructing below. + const bodyEnc = encodeURIComponent(doc.bodyHtml); + const cssEnc = encodeURIComponent(doc.css); + const titleEnc = encodeURIComponent(doc.title); + + // Runs inside the webview's renderer process. + return ` + (() => { + const body = decodeURIComponent("${bodyEnc}"); + const css = decodeURIComponent("${cssEnc}"); + const title = decodeURIComponent("${titleEnc}"); + + // Replace head with a single <style> block from captured CSS plus a + // minimal print reset. We don't try to preserve the original head — the + // host page is just a render context. + const styleEl = document.createElement("style"); + styleEl.textContent = css; + document.head.innerHTML = ""; + document.head.appendChild(styleEl); + + // Force light theme — dark backgrounds print poorly. This matches the + // better-export-pdf approach. + document.body.className = "theme-light"; + document.body.removeAttribute("style"); + document.body.innerHTML = body; + + document.title = title; + })(); + `; +} + +const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms)); diff --git a/src/export/printScaffold.ts b/src/export/printScaffold.ts new file mode 100644 index 0000000..9c5eb36 --- /dev/null +++ b/src/export/printScaffold.ts @@ -0,0 +1,217 @@ +/** + * Pure helpers for assembling a print-ready HTML document. + * + * Lives separately from htmlRenderer.ts so the integration test (which + * cannot import `obsidian`) can pull the same scaffold + CSS the runtime + * uses. Keep both in sync by importing from here. + */ + +/** + * Wrap rendered inner HTML in the standard `.print` scaffold. + * The outer `.print` div scopes the print-patch CSS without leaking globally. + * + * Does NOT inject an h1 title — the source markdown is expected to carry its + * own `# Title` heading, which the markdown renderer turns into an h1. Adding + * a second h1 here would double-render the title. + * + * @param _title Reserved for future use (PDF metadata / scaffold-only paths). + * Currently unused in the body but kept in the signature so + * callers continue to pass it; we may surface it as a header + * or fallback when there's no leading h1 in the markdown. + */ +export function wrapInPrintScaffold(_title: string, innerHtml: string): string { + return `<div class="print"><div class="markdown-preview-view markdown-rendered">${innerHtml}</div></div>`; +} + +export function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """); +} + +/** + * Print-specific CSS patches. Forces white background, sensible margins, sane + * page-break behaviour around tables, and a printable title style. + * + * Applied on TOP of whatever theme CSS was captured at runtime — its job is + * to override any dark/unreadable theme rules and add print-specific + * formatting (page breaks, table layout). + */ +export const PRINT_PATCH_CSS = ` + /* duckmage export print patches */ + html, body { + background: #ffffff !important; + color: #111111 !important; + margin: 0; + padding: 0; + } + body.theme-light { + --background-primary: #ffffff; + --text-normal: #111111; + } + .print { + padding: 0; + background: #ffffff; + } + .print .markdown-preview-view, + .print .markdown-rendered { + background: #ffffff !important; + color: #111111 !important; + padding: 0.5in 0.6in; + max-width: none; + height: auto !important; + } + /* Title style — applies to the markdown's leading h1 inside the print scaffold */ + .print h1:first-child { + font-size: 1.7em; + margin: 0 0 0.6em 0; + padding-bottom: 0.3em; + border-bottom: 2px solid #333; + } + + /* Constrain images so a huge source asset doesn't blow out a page. + max-height of 4in (~ 40% of a Letter page) keeps the heading + image + + caption able to share a page comfortably, while still showing the image + at a useful size for a printed game aid. Small images render at their + natural size (width: auto, no min sizing). */ + .print img { + max-width: 100%; + max-height: 4in; + width: auto; + height: auto; + object-fit: contain; + } + + /* Map-PDF specific overrides: the cover-page map fills the page; the + section-page map sits at the top in landscape mode and gets ~ 40% of + the available height. */ + .print img.duckmage-pdf-fullmap { + display: block; + width: 100%; + height: auto; + max-height: 6.5in; + margin: 0 auto; + object-fit: contain; + } + .print img.duckmage-pdf-sectionmap { + display: block; + width: 100%; + height: auto; + max-height: 3.6in; + margin: 0 auto 0.6em auto; + object-fit: contain; + } + + /* Explicit page-break marker used by the map PDF exporter. The empty div + forces the renderer to start a new page even when the previous content + leaves room. */ + .print .duckmage-pdf-pagebreak { + break-before: page; + page-break-before: always; + height: 0; + margin: 0; + padding: 0; + } + + /* Workflow sample blocks. Each sampled output gets its own visually + separated block. */ + .print .duckmage-workflow-sample { + margin-top: 1.4em; + padding-top: 1em; + border-top: 1px solid #ddd; + } + .print .duckmage-workflow-sample:first-of-type { + margin-top: 1em; + padding-top: 0; + border-top: none; + } + .print .duckmage-workflow-sample > h3 { + margin-top: 0; + font-size: 1.1em; + } + .print .duckmage-workflow-sample table { + margin-bottom: 1em; + } + + /* Single-hex export sections. Each section gets generous breathing room + and a subtle top border for visual separation. The first section is + borderless so it doesn't double up with the metadata header above. */ + .print .duckmage-hex-section { + margin-top: 1.6em; + padding-top: 1.2em; + border-top: 1px solid #ddd; + } + .print .duckmage-hex-section:first-of-type { + margin-top: 1.2em; + padding-top: 0; + border-top: none; + } + .print .duckmage-hex-section > h2 { + margin-top: 0; + font-size: 1.25em; + } + .print .duckmage-hex-section ul { + margin: 0.4em 0 0.6em 1.3em; + } + .print .duckmage-hex-section p { + line-height: 1.5; + margin: 0.4em 0 0.6em 0; + } + + /* Each linked-note entry in the appendix. Visual separation via top border; + break control via break-inside: avoid-page (in @media print below). */ + .print .duckmage-export-note { + border-top: 1px solid #ddd; + margin-top: 1.4em; + padding-top: 1em; + } + .print .duckmage-export-note-first { + border-top: 2px solid #999; + margin-top: 2em; + padding-top: 1.4em; + } + .print .duckmage-export-note > h2 { + margin-top: 0; + } + + @media print { + table { break-inside: auto; width: 100%; border-collapse: collapse; } + thead { display: table-header-group; } + tr { break-inside: avoid; break-after: auto; } + th, td { + border: 1px solid #ccc; + padding: 4px 8px; + text-align: left; + vertical-align: top; + } + + /* Keep headings tied to what follows them. break-after on the heading + itself + break-before:avoid on the immediate next sibling. */ + h1, h2, h3, h4 { break-after: avoid; } + h2 + *, h3 + *, h4 + * { break-before: avoid; } + + /* Paragraph orphan/widow control. */ + p { orphans: 3; widows: 3; } + + /* Images shouldn't split across pages. */ + img, figure { break-inside: avoid; } + + /* Tighter table cells for dense map-PDF reference tables (many columns + on a landscape page) so all hex link sections fit without wrapping + awkwardly. */ + .print table { font-size: 0.78em; } + .print th, .print td { padding: 3px 5px; } + + /* Each linked-note entry: keep heading + body together. If the entire + entry doesn't fit on the remaining page, the browser starts it on a + new page. Entries larger than one page will split, which is fine — + this only prevents the avoidable orphan case. */ + .duckmage-export-note { break-inside: avoid-page; } + /* Same treatment for single-hex export sections. */ + .duckmage-hex-section { break-inside: avoid-page; } + /* And for workflow sample blocks. */ + .duckmage-workflow-sample { break-inside: avoid-page; } + } +`; diff --git a/src/hex-map/HexEditorModal.ts b/src/hex-map/HexEditorModal.ts index c326189..6e6f84b 100644 --- a/src/hex-map/HexEditorModal.ts +++ b/src/hex-map/HexEditorModal.ts @@ -24,8 +24,9 @@ import { } from "../sections"; import { FileLinkSuggestModal } from "./FileLinkSuggestModal"; import { TEXT_SECTIONS } from "../types"; -import type { LinkSection, HexEditorOptions } from "../types"; +import type { LinkSection, HexEditorOptions, TerrainColor } from "../types"; import { RandomTableModal } from "../random-tables/RandomTableModal"; +import { HexExportModal } from "./HexExportModal"; import { VIEW_TYPE_HEX_MAP, VIEW_TYPE_RANDOM_TABLES } from "../constants"; export class HexEditorModal extends HexmakerModal { @@ -125,6 +126,14 @@ export class HexEditorModal extends HexmakerModal { void this.app.workspace.getLeaf("tab").openFile(fileNow); this.close(); }); + const exportLink = titleLeft.createEl("a", { + text: "Export", + cls: "duckmage-editor-open-link", + }); + exportLink.title = "Export this hex as PDF or Markdown"; + exportLink.addEventListener("click", () => { + new HexExportModal(this.app, this.plugin, fileNow).open(); + }); } this.renderNeighborWidget(titleRow, this.x, this.y); @@ -287,9 +296,12 @@ export class HexEditorModal extends HexmakerModal { // notShifted = the hex sits on the non-staggered (un-shifted) row/col. const notShifted = (n: number) => stagger === "odd" ? n % 2 === 0 : n % 2 !== 0; - const paletteMap = new Map( - this.plugin.getMapPalette(this.mapName).map((p) => [p.name, p]), - ); + // First-wins on duplicate-named palette entries — matches HexMapView's + // .find() behaviour so the neighbor preview agrees with the main grid. + const paletteMap = new Map<string, TerrainColor>(); + for (const p of this.plugin.getMapPalette(this.mapName)) { + if (!paletteMap.has(p.name)) paletteMap.set(p.name, p); + } const widget = container.createDiv({ cls: "duckmage-neighbor-widget" }); type NeighborDef = { l: number; t: number; nx: number; ny: number }; diff --git a/src/hex-map/HexExportModal.ts b/src/hex-map/HexExportModal.ts new file mode 100644 index 0000000..9350ad6 --- /dev/null +++ b/src/hex-map/HexExportModal.ts @@ -0,0 +1,115 @@ +/** + * Export options modal for a single hex (Phase 1.5). + * + * Currently exposes file name + GM-sections toggle + format buttons. The + * architecture is set up to grow: future options (per-section toggles, + * "include related random tables", linked-note expansions, etc.) slot in as + * new rows in `renderOptions`. The orchestrator wires those toggles into + * `SingleHexExportOptions` without needing structural changes. + */ + +import { App, TFile } from "obsidian"; +import { HexmakerModal } from "../HexmakerModal"; +import type HexmakerPlugin from "../HexmakerPlugin"; +import { + exportHexAsPdf, + exportHexAsMarkdown, +} from "../export/exporters/singleHex"; + +export class HexExportModal extends HexmakerModal { + constructor( + app: App, + private plugin: HexmakerPlugin, + private hexFile: TFile, + private defaultName?: string, + ) { + super(app); + } + + onOpen(): void { + this.titleEl.setText(`Export hex: ${this.hexFile.basename}`); + this.makeDraggable(); + this.render(); + } + + private render(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("duckmage-hex-export-modal"); + + const baseName = this.defaultName ?? this.hexFile.basename; + + contentEl.createEl("p", { + cls: "duckmage-export-tab-hint", + text: "Export this hex as a structured PDF or Markdown file. Each section is rendered as its own block with generous spacing.", + }); + + const optsForm = contentEl.createDiv({ cls: "duckmage-export-tab-options" }); + + // File name + const nameRow = optsForm.createDiv({ cls: "duckmage-export-tab-row" }); + nameRow.createEl("label", { + text: "File name", + cls: "duckmage-export-tab-label", + }); + const nameInput = nameRow.createEl("input", { + type: "text", + cls: "duckmage-export-tab-text", + attr: { placeholder: baseName }, + }); + nameInput.value = baseName; + + // Include-GM toggle. Default OFF — GM-only sections should be opt-in. + const gmRow = optsForm.createDiv({ cls: "duckmage-export-tab-row" }); + const gmCb = gmRow.createEl("input", { + type: "checkbox", + cls: "duckmage-export-tab-checkbox", + }); + gmCb.checked = false; + /* eslint-disable obsidianmd/ui/sentence-case -- GM is an acronym; Hidden/Secret are section names */ + gmRow.createEl("label", { + text: "Include GM-only sections (Hidden, Secret)", + cls: "duckmage-export-tab-label", + }); + /* eslint-enable obsidianmd/ui/sentence-case */ + gmRow.addEventListener("click", (e) => { + if (e.target instanceof HTMLInputElement) return; + gmCb.checked = !gmCb.checked; + }); + + // Live filename preview + const preview = contentEl.createDiv({ cls: "duckmage-export-tab-preview" }); + const buildStem = (): string => + nameInput.value.trim() || baseName; + const updatePreview = () => { + preview.setText(`Output: ${buildStem()}.pdf / ${buildStem()}.md`); + }; + nameInput.addEventListener("input", updatePreview); + updatePreview(); + + // Action buttons + const actions = contentEl.createDiv({ cls: "duckmage-export-tab-actions" }); + const pdfBtn = actions.createEl("button", { + cls: "mod-cta", + text: "Export PDF", + }); + pdfBtn.addEventListener("click", () => { + void exportHexAsPdf(this.plugin, this.hexFile, { + outputName: buildStem(), + includeGmSections: gmCb.checked, + }); + this.close(); + }); + const mdBtn = actions.createEl("button", { + cls: "mod-cta", + text: "Export Markdown", + }); + mdBtn.addEventListener("click", () => { + void exportHexAsMarkdown(this.plugin, this.hexFile, { + outputName: buildStem(), + includeGmSections: gmCb.checked, + }); + this.close(); + }); + } +} diff --git a/src/hex-map/MapModal.ts b/src/hex-map/MapModal.ts index fd168c6..8710c92 100644 --- a/src/hex-map/MapModal.ts +++ b/src/hex-map/MapModal.ts @@ -4,8 +4,35 @@ import type HexmakerPlugin from "../HexmakerPlugin"; import type { HexMapView } from "./HexMapView"; import { normalizeFolder, slugify, getIconUrl, createIconEl } from "../utils"; import { getSubmapFromFile, setSubmapInFile } from "../frontmatter"; +import { exportMapAsPng } from "../export/mapPngRenderer"; +import { exportMapAsPdf } from "../export/exporters/mapWithTable"; -type ModalTab = "Maps" | "Properties" | "New map"; +type ModalTab = "Maps" | "Properties" | "New map" | "Export"; + +function makeCheckbox( + parent: HTMLElement, + labelText: string, + initial: boolean, +): HTMLInputElement { + const row = parent.createDiv({ cls: "duckmage-export-tab-row" }); + const cb = row.createEl("input", { + type: "checkbox", + cls: "duckmage-export-tab-checkbox", + }); + cb.checked = initial; + row.createEl("label", { text: labelText, cls: "duckmage-export-tab-label" }); + // Make the label clickable to toggle the box. + row.addEventListener("click", (e) => { + if (e.target instanceof HTMLInputElement) return; + cb.checked = !cb.checked; + }); + return cb; +} + +function clampInt(v: number, lo: number, hi: number, fallback: number): number { + if (Number.isNaN(v)) return fallback; + return Math.max(lo, Math.min(hi, v)); +} export class MapModal extends HexmakerModal { private confirmingDelete: string | null = null; @@ -33,7 +60,7 @@ export class MapModal extends HexmakerModal { // Tab bar const tabBar = contentEl.createDiv({ cls: "duckmage-rt-mode-tabs duckmage-map-modal-tabs" }); - const tabNames: ModalTab[] = ["Maps", "Properties", "New map"]; + const tabNames: ModalTab[] = ["Maps", "Properties", "New map", "Export"]; const contentDivs = new Map<ModalTab, HTMLElement>(); for (const tab of tabNames) { @@ -63,6 +90,137 @@ export class MapModal extends HexmakerModal { this.renderMapsTab(contentDivs.get("Maps")!); this.renderPropertiesTab(contentDivs.get("Properties")!); this.renderNewMapTab(contentDivs.get("New map")!); + this.renderExportTab(contentDivs.get("Export")!); + } + + // ── Export tab ──────────────────────────────────────────────────────────── + + private renderExportTab(el: HTMLElement): void { + el.empty(); + el.addClass("duckmage-export-tab"); + + const mapName = this.view.activeMapName; + + el.createEl("p", { + cls: "duckmage-export-tab-hint", + text: `Export the active map "${mapName}" as a PNG. The file is written to the configured export folder and opened in a new tab.`, + }); + + const optsForm = el.createDiv({ cls: "duckmage-export-tab-options" }); + + // File name override — defaults to the map name; suffixes are auto-appended + // based on overlay checkboxes so the user can do successive variants without + // hand-renaming each export. + const nameRow = optsForm.createDiv({ cls: "duckmage-export-tab-row" }); + nameRow.createEl("label", { + text: "File name", + cls: "duckmage-export-tab-label", + }); + const nameInput = nameRow.createEl("input", { + type: "text", + cls: "duckmage-export-tab-text", + attr: { placeholder: mapName }, + }); + nameInput.value = mapName; + + const showCoords = makeCheckbox( + optsForm, + "Show coordinate labels", + true, + ); + const showIcons = makeCheckbox( + optsForm, + "Show terrain / override icons", + true, + ); + const showPaths = makeCheckbox( + optsForm, + "Include paths (roads, rivers, etc.)", + true, + ); + const showFactionOverlay = makeCheckbox( + optsForm, + "Include faction overlay", + false, + ); + const showRegionOverlay = makeCheckbox( + optsForm, + "Include region overlay", + false, + ); + + // Output size: a dropdown of presets that map to a hex-radius value. + // The actual PNG dimensions depend on grid size too, so we phrase the + // presets by hex pixel size + approximate use-case. + const sizeRow = optsForm.createDiv({ cls: "duckmage-export-tab-row" }); + sizeRow.createEl("label", { + text: "Output size", + cls: "duckmage-export-tab-label", + }); + const sizeSelect = sizeRow.createEl("select", { + cls: "duckmage-export-tab-select", + }); + const sizePresets: { label: string; radius: number }[] = [ + { label: "Small (30px hexes — quick preview)", radius: 30 }, + { label: "Medium (50px hexes — standard)", radius: 50 }, + { label: "Large (80px hexes — print quality)", radius: 80 }, + { label: "Huge (120px hexes — max detail)", radius: 120 }, + ]; + for (const preset of sizePresets) { + const opt = sizeSelect.createEl("option", { + text: preset.label, + value: String(preset.radius), + }); + if (preset.radius === 50) opt.selected = true; + } + + // Live filename preview combines the user's base name with any overlay + // suffixes. Both suffixes attach in the order faction → region so multiple + // exports of the same map produce a predictable filename family. The + // preview shows the stem only — each export button adds its own extension. + const preview = el.createDiv({ cls: "duckmage-export-tab-preview" }); + const buildStem = (): string => { + const base = nameInput.value.trim() || mapName; + let suffix = ""; + if (showFactionOverlay.checked) suffix += "-faction"; + if (showRegionOverlay.checked) suffix += "-region"; + return base + suffix; + }; + const updatePreview = () => { + preview.setText(`Output: ${buildStem()}.png / ${buildStem()}.pdf`); + }; + nameInput.addEventListener("input", updatePreview); + showFactionOverlay.addEventListener("change", updatePreview); + showRegionOverlay.addEventListener("change", updatePreview); + updatePreview(); + + const collectOpts = () => ({ + outputName: buildStem(), + hexRadius: clampInt(parseInt(sizeSelect.value, 10), 10, 200, 50), + showCoords: showCoords.checked, + showIcons: showIcons.checked, + showPaths: showPaths.checked, + showFactionOverlay: showFactionOverlay.checked, + showRegionOverlay: showRegionOverlay.checked, + }); + + const actions = el.createDiv({ cls: "duckmage-export-tab-actions" }); + const exportPngBtn = actions.createEl("button", { + cls: "mod-cta", + text: "Export PNG", + }); + exportPngBtn.addEventListener("click", () => { + void exportMapAsPng(this.plugin, mapName, collectOpts()); + this.close(); + }); + const exportPdfBtn = actions.createEl("button", { + cls: "mod-cta", + text: "Export PDF with reference table", + }); + exportPdfBtn.addEventListener("click", () => { + void exportMapAsPdf(this.plugin, mapName, collectOpts()); + this.close(); + }); } // ── Maps tab ────────────────────────────────────────────────────────────── diff --git a/src/hex-map/hexGeometry.ts b/src/hex-map/hexGeometry.ts index 2f3588f..54dd457 100644 --- a/src/hex-map/hexGeometry.ts +++ b/src/hex-map/hexGeometry.ts @@ -5,6 +5,108 @@ type Pt = { cx: number; cy: number }; +const SQRT3 = Math.sqrt(3); + +// ── Hex sizing ──────────────────────────────────────────────────────────────── + +/** + * Compute the (width, height) of a single hex of the given radius and orientation. + * Radius = distance from hex centre to any vertex. + * + * - Flat-top hex: width = 2R, height = √3·R + * - Pointy-top hex: width = √3·R, height = 2R + */ +export function hexSize( + hexRadius: number, + orientation: "flat" | "pointy", +): { w: number; h: number } { + return orientation === "flat" + ? { w: 2 * hexRadius, h: SQRT3 * hexRadius } + : { w: SQRT3 * hexRadius, h: 2 * hexRadius }; +} + +/** + * Centre pixel of the hex at grid (col, row) when the grid's top-left corner is + * pinned to (0, 0). Caller should add any padding by translating the result. + * + * - Flat-top: column spacing = 1.5R, row spacing = √3·R. Shifted columns + * push the cell DOWN by half a hex height. + * - Pointy-top: row spacing = 1.5R, column spacing = √3·R. Shifted rows + * push the cell RIGHT by half a hex width. + * + * `staggerOffset` controls which parity is the shifted one — "odd" (default) + * means odd columns/rows are shifted; "even" inverts. + */ +export function hexCenter( + col: number, + row: number, + orientation: "flat" | "pointy", + hexRadius: number, + staggerOffset: "odd" | "even" = "odd", +): Pt { + const { w, h } = hexSize(hexRadius, orientation); + const isShifted = (n: number) => + staggerOffset === "odd" ? n % 2 !== 0 : n % 2 === 0; + + if (orientation === "flat") { + const cx = hexRadius + col * 1.5 * hexRadius; + const cy = h / 2 + row * h + (isShifted(col) ? h / 2 : 0); + return { cx, cy }; + } + const cy = hexRadius + row * 1.5 * hexRadius; + const cx = w / 2 + col * w + (isShifted(row) ? w / 2 : 0); + return { cx, cy }; +} + +/** + * The six vertices of a hex centred at (cx, cy), in clockwise order starting + * from the rightmost vertex for flat-top (or upper-right for pointy-top). + */ +export function hexPolygonPoints( + cx: number, + cy: number, + orientation: "flat" | "pointy", + hexRadius: number, +): { x: number; y: number }[] { + const vStart = orientation === "flat" ? 0 : Math.PI / 6; + return Array.from({ length: 6 }, (_, i) => { + const a = vStart + (i * Math.PI) / 3; + return { + x: cx + hexRadius * Math.cos(a), + y: cy + hexRadius * Math.sin(a), + }; + }); +} + +/** + * Compute the pixel bounding box for an entire grid of hexes. Useful for + * sizing a canvas to fit all hexes plus optional padding. + */ +export function gridBoundingBox( + cols: number, + rows: number, + orientation: "flat" | "pointy", + hexRadius: number, + staggerOffset: "odd" | "even" = "odd", +): { width: number; height: number } { + // Walk the four corners + one staggered cell to find the extent. + // Cheaper than checking every cell since the grid is regular. + let maxRight = 0; + let maxBottom = 0; + const check = (col: number, row: number) => { + const c = hexCenter(col, row, orientation, hexRadius, staggerOffset); + if (c.cx + hexRadius > maxRight) maxRight = c.cx + hexRadius; + if (c.cy + hexRadius > maxBottom) maxBottom = c.cy + hexRadius; + }; + check(cols - 1, rows - 1); + // Also check the staggered-row/col extreme cell, which may protrude further. + if (cols > 0 && rows > 0) { + check(cols - 1, rows - 2 >= 0 ? rows - 2 : 0); + check(cols - 2 >= 0 ? cols - 2 : 0, rows - 1); + } + return { width: Math.ceil(maxRight), height: Math.ceil(maxBottom) }; +} + // ── Neighbor calculation ────────────────────────────────────────────────────── export function hexNeighbors( diff --git a/src/hex-table/HexTableView.ts b/src/hex-table/HexTableView.ts index 4904d4b..50f44ed 100644 --- a/src/hex-table/HexTableView.ts +++ b/src/hex-table/HexTableView.ts @@ -606,7 +606,12 @@ export class HexTableView extends ItemView { tr.empty(); const palette = this.plugin.getMapPalette(region); - const paletteMap = new Map(palette.map((p) => [p.name, p])); + // First-wins on duplicate-named palette entries — matches HexMapView's + // .find() behaviour. + const paletteMap = new Map<string, typeof palette[number]>(); + for (const p of palette) { + if (!paletteMap.has(p.name)) paletteMap.set(p.name, p); + } const terrainName = getTerrainFromFile(this.app, path); const hasTown = (links.get("towns") ?? []).length > 0; diff --git a/src/random-tables/RandomTableView.ts b/src/random-tables/RandomTableView.ts index 7aa4427..e0af166 100644 --- a/src/random-tables/RandomTableView.ts +++ b/src/random-tables/RandomTableView.ts @@ -27,6 +27,10 @@ import { parseWorkflow, generateDefaultTemplate } from "./workflow"; import { Frontmatter } from "../frontmatter"; import { buildTree, renderFolderTree, type TreeNode } from "./FolderTree"; import { ConfirmDeleteModal } from "./ConfirmDeleteModal"; +import { + exportRandomTableAsPdf, + exportRandomTableAsMarkdown, +} from "../export/exporters/randomTable"; export const DIE_OPTIONS = [ { label: "— no die —", value: 0 }, @@ -1414,6 +1418,26 @@ export class RandomTableView extends ItemView { void this.app.workspace.getLeaf().openFile(file); }); + + const exportPdfLink = header.createEl("a", { + text: "Export PDF", + cls: "duckmage-rt-edit-link", + }); + exportPdfLink.title = "Export this table as a printable PDF"; + + exportPdfLink.addEventListener("click", () => { + void exportRandomTableAsPdf(this.plugin, file); + }); + + const exportMdLink = header.createEl("a", { + text: "Export Markdown", + cls: "duckmage-rt-edit-link", + }); + exportMdLink.title = "Export this table as clean Markdown"; + exportMdLink.addEventListener("click", () => { + void exportRandomTableAsMarkdown(this.plugin, file); + }); + if (table.linkedFolder) { const refreshBtn = header.createEl("a", { text: "Refresh", diff --git a/src/random-tables/WorkflowEditorModal.ts b/src/random-tables/WorkflowEditorModal.ts index 4fab847..c639431 100644 --- a/src/random-tables/WorkflowEditorModal.ts +++ b/src/random-tables/WorkflowEditorModal.ts @@ -2,6 +2,7 @@ import { App, TFile, Notice } from "obsidian"; import { HexmakerModal } from "../HexmakerModal"; import type HexmakerPlugin from "../HexmakerPlugin"; import { normalizeFolder } from "../utils"; +import { WorkflowExportModal } from "./WorkflowExportModal"; import { parseWorkflow, buildWorkflowContent, @@ -674,6 +675,12 @@ export class WorkflowEditorModal extends HexmakerModal { } }; + footer + .createEl("button", { text: "Export…", cls: "mod-cta" }) + .addEventListener("click", () => { + new WorkflowExportModal(this.app, this.plugin, this.file).open(); + }); + footer .createEl("button", { text: "Close", cls: "mod-cta" }) .addEventListener("click", () => this.close()); diff --git a/src/random-tables/WorkflowExportModal.ts b/src/random-tables/WorkflowExportModal.ts new file mode 100644 index 0000000..ae1097d --- /dev/null +++ b/src/random-tables/WorkflowExportModal.ts @@ -0,0 +1,145 @@ +/** + * Export options modal for a workflow (Phase 1.6). + * + * Exposes file name + sample count + template/breakdown toggles + format + * buttons. Like HexExportModal, the layout uses the existing + * `.duckmage-export-tab-*` CSS classes so it visually matches the Maps + * modal Export tab. + */ + +import { App, TFile } from "obsidian"; +import { HexmakerModal } from "../HexmakerModal"; +import type HexmakerPlugin from "../HexmakerPlugin"; +import { + exportWorkflowAsPdf, + exportWorkflowAsMarkdown, +} from "../export/exporters/workflow"; + +export class WorkflowExportModal extends HexmakerModal { + constructor( + app: App, + private plugin: HexmakerPlugin, + private workflowFile: TFile, + ) { + super(app); + } + + onOpen(): void { + this.titleEl.setText(`Export workflow: ${this.workflowFile.basename}`); + this.makeDraggable(); + this.render(); + } + + private render(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("duckmage-workflow-export-modal"); + + const baseName = this.workflowFile.basename; + + contentEl.createEl("p", { + cls: "duckmage-export-tab-hint", + text: "Export this workflow definition plus a batch of rolled sample outputs as a PDF or Markdown file.", + }); + + const optsForm = contentEl.createDiv({ cls: "duckmage-export-tab-options" }); + + // File name + const nameRow = optsForm.createDiv({ cls: "duckmage-export-tab-row" }); + nameRow.createEl("label", { + text: "File name", + cls: "duckmage-export-tab-label", + }); + const nameInput = nameRow.createEl("input", { + type: "text", + cls: "duckmage-export-tab-text", + attr: { placeholder: baseName }, + }); + nameInput.value = baseName; + + // Number of samples + const samplesRow = optsForm.createDiv({ cls: "duckmage-export-tab-row" }); + samplesRow.createEl("label", { + text: "Number of sample outputs", + cls: "duckmage-export-tab-label", + }); + const samplesInput = samplesRow.createEl("input", { + type: "number", + cls: "duckmage-export-tab-number", + }); + samplesInput.value = "5"; + samplesInput.min = "0"; + samplesInput.max = "50"; + samplesInput.step = "1"; + + // Include template + breakdown toggles + const templateCb = makeCheckbox( + optsForm, + "Include the workflow template", + true, + ); + const breakdownCb = makeCheckbox( + optsForm, + "Include the per-sample rolls breakdown", + true, + ); + + // Live filename preview + const preview = contentEl.createDiv({ cls: "duckmage-export-tab-preview" }); + const buildStem = (): string => nameInput.value.trim() || baseName; + const updatePreview = () => { + preview.setText(`Output: ${buildStem()}.pdf / ${buildStem()}.md`); + }; + nameInput.addEventListener("input", updatePreview); + updatePreview(); + + const collectOpts = () => ({ + outputName: buildStem(), + numSamples: clampInt(parseInt(samplesInput.value, 10), 0, 50, 5), + includeTemplate: templateCb.checked, + includeRollsBreakdown: breakdownCb.checked, + }); + + const actions = contentEl.createDiv({ cls: "duckmage-export-tab-actions" }); + const pdfBtn = actions.createEl("button", { + cls: "mod-cta", + text: "Export PDF", + }); + pdfBtn.addEventListener("click", () => { + void exportWorkflowAsPdf(this.plugin, this.workflowFile, collectOpts()); + this.close(); + }); + const mdBtn = actions.createEl("button", { + cls: "mod-cta", + text: "Export Markdown", + }); + mdBtn.addEventListener("click", () => { + void exportWorkflowAsMarkdown(this.plugin, this.workflowFile, collectOpts()); + this.close(); + }); + } +} + +function makeCheckbox( + parent: HTMLElement, + labelText: string, + initial: boolean, +): HTMLInputElement { + const row = parent.createDiv({ cls: "duckmage-export-tab-row" }); + const cb = row.createEl("input", { + type: "checkbox", + cls: "duckmage-export-tab-checkbox", + }); + cb.checked = initial; + row.createEl("label", { text: labelText, cls: "duckmage-export-tab-label" }); + row.addEventListener("click", (e) => { + if (e.target instanceof HTMLInputElement) return; + cb.checked = !cb.checked; + }); + return cb; +} + +function clampInt(v: number, lo: number, hi: number, fallback: number): number { + if (Number.isNaN(v)) return fallback; + return Math.max(lo, Math.min(hi, v)); +} diff --git a/src/types.ts b/src/types.ts index 2c7121e..4529b22 100644 --- a/src/types.ts +++ b/src/types.ts @@ -94,6 +94,7 @@ export interface HexmakerPluginSettings { defaultSubmapCols: number; defaultSubmapRows: number; workflowsFolder: string; + exportFolder: string; hiddenIcons: string[]; iconOrder: string[]; setupComplete: boolean; diff --git a/styles.css b/styles.css index abe113c..7bff7c2 100644 --- a/styles.css +++ b/styles.css @@ -1231,6 +1231,63 @@ input.duckmage-input-error { left: 44px; } +/* ── Export tab in the Maps modal ─────────────────────────────────────────── */ +.duckmage-export-tab { + padding: 0.5em 0; + display: flex; + flex-direction: column; + gap: 0.8em; +} +.duckmage-export-tab-hint { + color: var(--text-muted); + font-size: 0.9em; + margin: 0; +} +.duckmage-export-tab-options { + display: flex; + flex-direction: column; + gap: 0.4em; +} +.duckmage-export-tab-row { + display: flex; + align-items: center; + gap: 0.5em; + padding: 0.2em 0; + cursor: pointer; +} +.duckmage-export-tab-label { + cursor: pointer; +} +.duckmage-export-tab-number { + width: 80px; + margin-left: auto; +} +.duckmage-export-tab-text { + flex: 1; + margin-left: 0.5em; + min-width: 0; +} +.duckmage-export-tab-select { + flex: 1; + margin-left: 0.5em; + min-width: 0; +} +.duckmage-export-tab-preview { + font-family: var(--font-monospace); + font-size: 0.85em; + color: var(--text-muted); + padding: 0.3em 0; + word-break: break-all; +} +.duckmage-export-tab-actions { + display: flex; + justify-content: flex-end; + gap: 0.5em; + padding-top: 0.5em; + border-top: 1px solid var(--background-modifier-border); + flex-wrap: wrap; +} + .duckmage-undo-btn-map { left: 8px; top: 42px; @@ -4587,3 +4644,24 @@ tr:hover .duckmage-rt-entry-copy-btn:hover { color: var(--text-on-accent); border-color: var(--interactive-accent); } + +/* Export — hidden render host for printToPDF */ +.duckmage-export-webview { + position: fixed; + left: -10000px; + top: -10000px; + width: 850px; + height: 1100px; + visibility: hidden; + pointer-events: none; +} + +/* Export — off-screen render host for MarkdownRenderer */ +.duckmage-export-render-host { + position: fixed; + left: -10000px; + top: -10000px; + width: 850px; + visibility: hidden; + pointer-events: none; +} diff --git a/tests/fixtures/sample-hex.md b/tests/fixtures/sample-hex.md new file mode 100644 index 0000000..403b591 --- /dev/null +++ b/tests/fixtures/sample-hex.md @@ -0,0 +1,60 @@ +--- +terrain: forest +icon: bw-forest.png +--- + +### Description + +The trees lean north against an old wind. A scoured **standing stone** +juts from the moss near the centre of the hex, half-toppled. A footpath +crosses east-to-west, faint and overgrown. + +### Landmark + +Pale stone, lichen-mottled, taller than two riders. *Runes* visible at +its base when the moss is brushed aside. + +### Weather + +Misty mornings; warm afternoons. Light rain after midday in autumn. + +### Hooks & Rumors + +- The standing stone hums at midwinter, locals say. +- A masked rider was seen heading north along the path two evenings ago. + +### Hidden + +A weathered cache beneath the standing stone holds three gold ingots +and a wax-sealed letter addressed to "the wanderer of nine moons". + +### Secret + +The standing stone is one of seven; together they bind a sleeping power +beneath the [[hidden tomb]]. + +### Towns + +- [[Saltwatch]] + +### Dungeons + +- [[The Old Spire]] +- [[Sunken Crypt]] + +### Features + +- [[Standing Stone]] +- [[Forgotten Path]] + +### Quests + +- [[Find the Wanderer]] + +### Factions + +- [[The Reach]] + +### Encounters Table + +- [[Forest - Encounters]] diff --git a/tests/fixtures/sample-random-table.md b/tests/fixtures/sample-random-table.md new file mode 100644 index 0000000..f443f69 --- /dev/null +++ b/tests/fixtures/sample-random-table.md @@ -0,0 +1,15 @@ +--- +dice: 20 +--- + +A small fixture table used by the integration test. **Wandering monsters** for a forest hex. + +| Result | Weight | +|--------|--------| +| Pack of wolves | 4 | +| Lost merchant | 2 | +| Bandit ambush | 3 | +| Druid circle | 1 | +| Wild boar | 5 | +| Travelling minstrel | 2 | +| Goblin scouts | 3 | diff --git a/tests/fixtures/sample-town.md b/tests/fixtures/sample-town.md new file mode 100644 index 0000000..2ff6b22 --- /dev/null +++ b/tests/fixtures/sample-town.md @@ -0,0 +1,21 @@ +--- +tags: [town, coastal] +population: 320 +--- + +## Overview + +A small fishing town clinging to the southern cliffs. Houses are weathered grey +stone, roofs gull-white. Salt is the local currency in winter. + +## Notable Residents + +- **Maren Kell** — harbourmaster; runs the dock under an iron hand. +- **Old Tomeas** — the only smith for thirty miles. Drinks too much. +- **Sister Lyn** — keeps the lighthouse shrine; whispers of the deep. + +## Hooks & Rumors + +A merchant vessel was found drifting empty last spring. No one speaks of it +above a whisper. The lighthouse burns blue some nights — a *bad omen* the +older fisherfolk say. diff --git a/tests/fixtures/sim-vault/README.md b/tests/fixtures/sim-vault/README.md new file mode 100644 index 0000000..d7ece9d --- /dev/null +++ b/tests/fixtures/sim-vault/README.md @@ -0,0 +1,55 @@ +# sim-vault + +Synthetic vault used by the e2e integration tests. Mirrors a realistic +Duckmage setup but with deterministic, small content so test assertions +are stable. + +## Layout + +``` +sim-vault/ + hexes/ + the-coast/ + 0_0.md, 1_0.md, 2_0.md, 0_1.md, 1_1.md, 2_1.md ← 3×2 grid + tables/ + forest-encounters.md (d6, weighted) + npc-names.md (d20, plain list) + treasure.md (linkedFolder) + treasure/ + gold pile.md + silver ring.md + cursed amulet.md + workflows/ + npc-generator.md (table + dice + template) + templates/ + npc-template.md + towns/ + Saltwatch.md + dungeons/ + The Old Spire.md + features/ + Standing Stone.md + quests/ + Find the Wanderer.md + factions/ + The Reach.md (faction-color: #cd5c5c) + regions/ + Coastlands.md (region-color: #6e8eb1) +``` + +## Settings driven from this layout + +The `MockHexmakerPlugin` (tests/helpers/simVault.ts) wires: +- `hexFolder: "hexes"` +- `tablesFolder: "tables"` +- `workflowsFolder: "workflows"` +- `townsFolder: "towns"`, `dungeonsFolder: "dungeons"`, etc. +- `regionsFolder: "regions"`, `factionsFolder: "factions"` +- One map `"the-coast"` with `gridSize: 3×2, gridOffset: 0,0` and the + default terrain palette (including a duplicate-`"ocean"` entry so the + palette-collision regression is exercised). + +## Why fixed file contents + +Real vaults change. Committed fixture files keep assertions deterministic and +let new contributors inspect exactly what each section/frontmatter looks like. diff --git a/tests/fixtures/sim-vault/dungeons/The Old Spire.md b/tests/fixtures/sim-vault/dungeons/The Old Spire.md new file mode 100644 index 0000000..a3037f7 --- /dev/null +++ b/tests/fixtures/sim-vault/dungeons/The Old Spire.md @@ -0,0 +1,3 @@ +### Overview + +A crumbling watchtower of black stone, half-submerged at high tide. diff --git a/tests/fixtures/sim-vault/factions/The Reach.md b/tests/fixtures/sim-vault/factions/The Reach.md new file mode 100644 index 0000000..54fe1ed --- /dev/null +++ b/tests/fixtures/sim-vault/factions/The Reach.md @@ -0,0 +1,5 @@ +--- +faction-color: "#cd5c5c" +--- + +A coastal merchant alliance claiming most of the southern trade routes. diff --git a/tests/fixtures/sim-vault/features/Standing Stone.md b/tests/fixtures/sim-vault/features/Standing Stone.md new file mode 100644 index 0000000..3c555ca --- /dev/null +++ b/tests/fixtures/sim-vault/features/Standing Stone.md @@ -0,0 +1 @@ +A weather-worn standing stone, taller than two riders. diff --git a/tests/fixtures/sim-vault/hexes/the-coast/0_0.md b/tests/fixtures/sim-vault/hexes/the-coast/0_0.md new file mode 100644 index 0000000..83e54b1 --- /dev/null +++ b/tests/fixtures/sim-vault/hexes/the-coast/0_0.md @@ -0,0 +1,43 @@ +--- +terrain: shallows +--- + +### Description + +Calm shallow water with sandy banks. **Safe to wade** across at low tide. + +### Landmark + +A weathered pier juts into the bay; rotten boards groan with each wave. + +### Weather + +Foggy at dawn; clear by mid-morning. + +### Hooks & Rumors + +- A fisherman lost his ring here last spring. + +### Hidden + +A waterlogged satchel lies wedged beneath the pier. Contains *three silvers*. + +### Secret + +Below the pier, a small tunnel leads to [[The Old Spire]]. + +### Towns + +- [[Saltwatch]] + +### Dungeons + +- [[The Old Spire]] + +### Factions + +- [[The Reach]] + +### Encounters Table + +- [[forest-encounters]] diff --git a/tests/fixtures/sim-vault/hexes/the-coast/0_1.md b/tests/fixtures/sim-vault/hexes/the-coast/0_1.md new file mode 100644 index 0000000..42f55cc --- /dev/null +++ b/tests/fixtures/sim-vault/hexes/the-coast/0_1.md @@ -0,0 +1,8 @@ +--- +terrain: ocean +region: Coastlands +--- + +### Description + +Open ocean stretching to the southern horizon. diff --git a/tests/fixtures/sim-vault/hexes/the-coast/1_0.md b/tests/fixtures/sim-vault/hexes/the-coast/1_0.md new file mode 100644 index 0000000..eadc7b8 --- /dev/null +++ b/tests/fixtures/sim-vault/hexes/the-coast/1_0.md @@ -0,0 +1,16 @@ +--- +terrain: forest +region: Coastlands +--- + +### Description + +Dense pine forest, the air sharp with sap. + +### Features + +- [[Standing Stone]] + +### Quests + +- [[Find the Wanderer]] diff --git a/tests/fixtures/sim-vault/hexes/the-coast/1_1.md b/tests/fixtures/sim-vault/hexes/the-coast/1_1.md new file mode 100644 index 0000000..867b5d9 --- /dev/null +++ b/tests/fixtures/sim-vault/hexes/the-coast/1_1.md @@ -0,0 +1,15 @@ +--- +terrain: grass +--- + +### Description + +Rolling pastureland. + +### Towns + +- [[Saltwatch]] + +### Factions + +- [[The Reach]] diff --git a/tests/fixtures/sim-vault/hexes/the-coast/2_0.md b/tests/fixtures/sim-vault/hexes/the-coast/2_0.md new file mode 100644 index 0000000..a630309 --- /dev/null +++ b/tests/fixtures/sim-vault/hexes/the-coast/2_0.md @@ -0,0 +1,3 @@ +--- +terrain: mountain +--- diff --git a/tests/fixtures/sim-vault/hexes/the-coast/2_1.md b/tests/fixtures/sim-vault/hexes/the-coast/2_1.md new file mode 100644 index 0000000..c568da0 --- /dev/null +++ b/tests/fixtures/sim-vault/hexes/the-coast/2_1.md @@ -0,0 +1,3 @@ +--- +terrain: hills +--- diff --git a/tests/fixtures/sim-vault/quests/Find the Wanderer.md b/tests/fixtures/sim-vault/quests/Find the Wanderer.md new file mode 100644 index 0000000..ffdddec --- /dev/null +++ b/tests/fixtures/sim-vault/quests/Find the Wanderer.md @@ -0,0 +1 @@ +Recover a missing scout last seen heading into the [[Standing Stone]] hex. diff --git a/tests/fixtures/sim-vault/regions/Coastlands.md b/tests/fixtures/sim-vault/regions/Coastlands.md new file mode 100644 index 0000000..7162373 --- /dev/null +++ b/tests/fixtures/sim-vault/regions/Coastlands.md @@ -0,0 +1,5 @@ +--- +region-color: "#6e8eb1" +--- + +The narrow coastal strip from Saltwatch south to the cliffs. diff --git a/tests/fixtures/sim-vault/tables/forest-encounters.md b/tests/fixtures/sim-vault/tables/forest-encounters.md new file mode 100644 index 0000000..1dfdeaa --- /dev/null +++ b/tests/fixtures/sim-vault/tables/forest-encounters.md @@ -0,0 +1,12 @@ +--- +dice: 6 +--- + +Weighted forest encounter table. + +| Result | Weight | +|--------|--------| +| Pack of wolves | 2 | +| Lost merchant | 1 | +| Bandit ambush | 2 | +| Druid circle | 1 | diff --git a/tests/fixtures/sim-vault/tables/npc-names.md b/tests/fixtures/sim-vault/tables/npc-names.md new file mode 100644 index 0000000..46e5bce --- /dev/null +++ b/tests/fixtures/sim-vault/tables/npc-names.md @@ -0,0 +1,11 @@ +--- +dice: 20 +--- + +| Result | Weight | +|--------|--------| +| Bromund | 1 | +| Lirel | 1 | +| Vargax | 1 | +| Saskia | 1 | +| Olwen | 1 | diff --git a/tests/fixtures/sim-vault/tables/treasure.md b/tests/fixtures/sim-vault/tables/treasure.md new file mode 100644 index 0000000..698674c --- /dev/null +++ b/tests/fixtures/sim-vault/tables/treasure.md @@ -0,0 +1,11 @@ +--- +linkedFolder: tables/treasure +--- + +Auto-synced from the treasure folder. + +| Result | Weight | +|--------|--------| +| [[gold pile]] | 1 | +| [[silver ring]] | 1 | +| [[cursed amulet]] | 1 | diff --git a/tests/fixtures/sim-vault/tables/treasure/cursed amulet.md b/tests/fixtures/sim-vault/tables/treasure/cursed amulet.md new file mode 100644 index 0000000..6358164 --- /dev/null +++ b/tests/fixtures/sim-vault/tables/treasure/cursed amulet.md @@ -0,0 +1 @@ +Black iron amulet that whispers at midnight. **Wearer becomes restless.** diff --git a/tests/fixtures/sim-vault/tables/treasure/gold pile.md b/tests/fixtures/sim-vault/tables/treasure/gold pile.md new file mode 100644 index 0000000..b01db3b --- /dev/null +++ b/tests/fixtures/sim-vault/tables/treasure/gold pile.md @@ -0,0 +1 @@ +A heap of unminted gold ingots wrapped in a moldering cloak. diff --git a/tests/fixtures/sim-vault/tables/treasure/silver ring.md b/tests/fixtures/sim-vault/tables/treasure/silver ring.md new file mode 100644 index 0000000..4416b6a --- /dev/null +++ b/tests/fixtures/sim-vault/tables/treasure/silver ring.md @@ -0,0 +1 @@ +A silver ring etched with a *running stag*. Slightly tarnished. diff --git a/tests/fixtures/sim-vault/towns/Saltwatch.md b/tests/fixtures/sim-vault/towns/Saltwatch.md new file mode 100644 index 0000000..5cc661a --- /dev/null +++ b/tests/fixtures/sim-vault/towns/Saltwatch.md @@ -0,0 +1,12 @@ +--- +population: 320 +--- + +### Overview + +A small fishing town clinging to the cliffs. + +### Notable Residents + +- **Maren Kell** — harbourmaster. +- **Old Tomeas** — smith. diff --git a/tests/fixtures/sim-vault/workflows/npc-generator.md b/tests/fixtures/sim-vault/workflows/npc-generator.md new file mode 100644 index 0000000..0cb3c42 --- /dev/null +++ b/tests/fixtures/sim-vault/workflows/npc-generator.md @@ -0,0 +1,11 @@ +--- +template-file: workflows/templates/npc-template +results-folder: workflows/results +--- + +Generate a random NPC with name, occupation, and stats. + +| Table | Rolls | Label | +|-------|-------|-------| +| [[npc-names]] | 1 | name | +| 2d6+3 | 1 | hp | diff --git a/tests/fixtures/sim-vault/workflows/templates/npc-template.md b/tests/fixtures/sim-vault/workflows/templates/npc-template.md new file mode 100644 index 0000000..acada0f --- /dev/null +++ b/tests/fixtures/sim-vault/workflows/templates/npc-template.md @@ -0,0 +1,3 @@ +# $name + +A traveller with **HP $hp** wanders the road. diff --git a/tests/helpers/simVault.ts b/tests/helpers/simVault.ts new file mode 100644 index 0000000..8fb15f9 --- /dev/null +++ b/tests/helpers/simVault.ts @@ -0,0 +1,423 @@ +/** + * Mock `App` / `Vault` / `MetadataCache` + `HexmakerPlugin` that read from + * the sim-vault fixture on real disk. Lets integration tests drive the + * actual exporter orchestrators end-to-end without spinning up Obsidian. + * + * The mocks implement only the surface area our orchestrators touch: + * vault.read / getAbstractFileByPath / getMarkdownFiles / createBinary / + * modifyBinary / create / modify + * metadataCache.getFileCache / getFirstLinkpathDest + * workspace.getLeaf().openFile() ← no-op + * plugin.app / plugin.settings / plugin.hexPath + * + * The metadata cache is built lazily per-file by an inline parser that + * handles YAML frontmatter (simple key:value), `### Headings` (level 3), + * and `[[wikilinks]]` — enough for everything our exporters depend on. + */ + +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; + +export const SIM_VAULT_ROOT = path.resolve( + __dirname, + "../fixtures/sim-vault", +); + +// ─── Minimal TFile/TFolder polyfills ─────────────────────────────────────── + +export class MockTFile { + path: string; + basename: string; + extension: string; + constructor(p: string) { + this.path = p; + const slash = p.lastIndexOf("/"); + const name = slash >= 0 ? p.slice(slash + 1) : p; + const dot = name.lastIndexOf("."); + this.basename = dot >= 0 ? name.slice(0, dot) : name; + this.extension = dot >= 0 ? name.slice(dot + 1) : ""; + } +} + +export class MockTFolder { + path: string; + constructor(p: string) { + this.path = p; + } +} + +// ─── Filesystem-backed Vault ─────────────────────────────────────────────── + +export class MockVault { + /** Output dir for created/modified files. Defaults to a sub-tmp. */ + outDir: string; + /** Cache of absolute paths → TFile, populated lazily. */ + private fileCache = new Map<string, MockTFile>(); + + constructor(outDir: string) { + this.outDir = outDir; + } + + /** + * Resolve a vault-relative path to an absolute filesystem path. + * Read-only files come from SIM_VAULT_ROOT; written files go to outDir. + * On read, prefer outDir if the file was written there (overrides). + */ + resolvePath(vaultPath: string, write: boolean): string { + if (write) return path.join(this.outDir, vaultPath); + // For reads: try outDir (in case we created/modified it), then sim-vault. + const outPath = path.join(this.outDir, vaultPath); + if (fs.existsSync(outPath)) return outPath; + return path.join(SIM_VAULT_ROOT, vaultPath); + } + + // ── Lookup ───────────────────────────────────────────────────────────── + + getAbstractFileByPath(p: string): MockTFile | MockTFolder | null { + const cached = this.fileCache.get(p); + if (cached) return cached; + const inSim = path.join(SIM_VAULT_ROOT, p); + const inOut = path.join(this.outDir, p); + let real = ""; + if (fs.existsSync(inOut)) real = inOut; + else if (fs.existsSync(inSim)) real = inSim; + if (!real) return null; + const stat = fs.statSync(real); + if (stat.isDirectory()) return new MockTFolder(p); + const file = new MockTFile(p); + this.fileCache.set(p, file); + return file; + } + + /** Return all .md files under both sim-vault and outDir, deduplicated. */ + getMarkdownFiles(): MockTFile[] { + const found = new Map<string, MockTFile>(); + const walk = (root: string, prefix: string) => { + if (!fs.existsSync(root)) return; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + const childAbs = path.join(root, entry.name); + const childRel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + walk(childAbs, childRel); + } else if (entry.isFile() && entry.name.endsWith(".md")) { + if (!found.has(childRel)) found.set(childRel, new MockTFile(childRel)); + } + } + }; + walk(SIM_VAULT_ROOT, ""); + walk(this.outDir, ""); + return Array.from(found.values()); + } + + // ── Read ─────────────────────────────────────────────────────────────── + + async read(file: MockTFile): Promise<string> { + const abs = this.resolvePath(file.path, false); + return await fsp.readFile(abs, "utf8"); + } + + async cachedRead(file: MockTFile): Promise<string> { + return this.read(file); + } + + // ── Write ────────────────────────────────────────────────────────────── + + async create(p: string, data: string): Promise<MockTFile> { + const abs = path.join(this.outDir, p); + await fsp.mkdir(path.dirname(abs), { recursive: true }); + await fsp.writeFile(abs, data, "utf8"); + const f = new MockTFile(p); + this.fileCache.set(p, f); + return f; + } + + async modify(file: MockTFile, data: string): Promise<void> { + const abs = path.join(this.outDir, file.path); + await fsp.mkdir(path.dirname(abs), { recursive: true }); + await fsp.writeFile(abs, data, "utf8"); + } + + async createBinary(p: string, data: ArrayBuffer): Promise<MockTFile> { + const abs = path.join(this.outDir, p); + await fsp.mkdir(path.dirname(abs), { recursive: true }); + await fsp.writeFile(abs, Buffer.from(data)); + const f = new MockTFile(p); + this.fileCache.set(p, f); + return f; + } + + async modifyBinary(file: MockTFile, data: ArrayBuffer): Promise<void> { + const abs = path.join(this.outDir, file.path); + await fsp.mkdir(path.dirname(abs), { recursive: true }); + await fsp.writeFile(abs, Buffer.from(data)); + } + + async createFolder(p: string): Promise<void> { + const abs = path.join(this.outDir, p); + await fsp.mkdir(abs, { recursive: true }); + } + + // Adapter API (Obsidian-style) + adapter = { + exists: async (p: string): Promise<boolean> => { + return ( + fs.existsSync(path.join(this.outDir, p)) || + fs.existsSync(path.join(SIM_VAULT_ROOT, p)) + ); + }, + getResourcePath: (p: string): string => { + // Real Obsidian returns app:// URLs. Tests don't load these via the + // browser; we just need a string. Use a file:// URL so failures are + // obvious if anything actually fetches it. + return `file://${this.resolvePath(p, false)}`; + }, + }; +} + +// ─── MetadataCache ───────────────────────────────────────────────────────── + +interface ParsedCache { + frontmatter: Record<string, unknown>; + headings: Array<{ + heading: string; + level: number; + position: { + start: { line: number; col: number; offset: number }; + end: { line: number; col: number; offset: number }; + }; + }>; + links: Array<{ + link: string; + position: { + start: { line: number; col: number; offset: number }; + end: { line: number; col: number; offset: number }; + }; + }>; + blocks: Record<string, unknown>; +} + +export class MockMetadataCache { + constructor(private vault: MockVault) {} + + getFileCache(file: MockTFile): ParsedCache | null { + try { + const abs = this.vault.resolvePath(file.path, false); + if (!fs.existsSync(abs)) return null; + const content = fs.readFileSync(abs, "utf8"); + return parseFileCache(content); + } catch { + return null; + } + } + + getFirstLinkpathDest( + linkpath: string, + _sourcePath: string, + ): MockTFile | null { + // Try linkpath as-is, with .md, as a folder/basename match. + const candidates = [linkpath, `${linkpath}.md`]; + for (const c of candidates) { + const f = this.vault.getAbstractFileByPath(c); + if (f instanceof MockTFile) return f; + } + // Fall back to a basename search across the vault. + const target = (linkpath.split("/").pop() ?? linkpath).replace(/\.md$/i, ""); + for (const f of this.vault.getMarkdownFiles()) { + if (f.basename === target) return f; + } + return null; + } + + fileToLinktext(file: MockTFile, _sourcePath: string): string { + return file.basename; + } +} + +// ─── Workspace (no-op) ───────────────────────────────────────────────────── + +class MockLeaf { + async openFile(_f: MockTFile): Promise<void> { + /* no-op */ + } + async setViewState(_s: unknown): Promise<void> { + /* no-op */ + } +} + +export class MockWorkspace { + getLeaf(_arg?: unknown): MockLeaf { + return new MockLeaf(); + } +} + +// ─── App ──────────────────────────────────────────────────────────────────── + +export class MockApp { + vault: MockVault; + metadataCache: MockMetadataCache; + workspace: MockWorkspace; + constructor(outDir: string) { + this.vault = new MockVault(outDir); + this.metadataCache = new MockMetadataCache(this.vault); + this.workspace = new MockWorkspace(); + } +} + +// ─── Plugin ──────────────────────────────────────────────────────────────── + +import { DEFAULT_SETTINGS } from "../../src/constants"; +import type { HexmakerPluginSettings } from "../../src/types"; + +export class MockHexmakerPlugin { + app: MockApp; + settings: HexmakerPluginSettings; + manifest = { dir: ".obsidian/plugins/duckmage-plugin", id: "duckmage" }; + // Field that real plugin uses for icon URL resolution. + vaultIconsSet = new Set<string>(); + + constructor(outDir: string) { + this.app = new MockApp(outDir); + this.settings = makeSimSettings(); + } + + hexPath(x: number, y: number, mapName: string): string { + const folder = this.settings.hexFolder; + return folder + ? `${folder}/${mapName}/${x}_${y}.md` + : `${mapName}/${x}_${y}.md`; + } + + getMap(mapName: string) { + return this.settings.maps.find((m) => m.name === mapName); + } + + getMapPalette(mapName: string) { + const map = this.getMap(mapName); + return ( + this.settings.terrainPalettes.find((p) => p.name === map?.paletteName) + ?.terrains ?? this.settings.terrainPalettes[0]?.terrains ?? [] + ); + } +} + +function makeSimSettings(): HexmakerPluginSettings { + // Start from real defaults but point folders + maps at the sim-vault layout. + const settings: HexmakerPluginSettings = JSON.parse( + JSON.stringify(DEFAULT_SETTINGS), + ); + settings.worldFolder = ""; + settings.hexFolder = "hexes"; + settings.tablesFolder = "tables"; + settings.workflowsFolder = "workflows"; + settings.townsFolder = "towns"; + settings.dungeonsFolder = "dungeons"; + settings.featuresFolder = "features"; + settings.questsFolder = "quests"; + settings.factionsFolder = "factions"; + settings.regionsFolder = "regions"; + settings.iconsFolder = ""; + settings.exportFolder = "exports"; + + // Single 3×2 map. + settings.maps = [ + { + name: "the-coast", + paletteName: settings.terrainPalettes[1]?.name ?? settings.terrainPalettes[0].name, + gridSize: { cols: 3, rows: 2 }, + gridOffset: { x: 0, y: 0 }, + pathChains: [], + }, + ]; + + // Inject a duplicate "ocean" entry into the palette to exercise the + // first-wins regression fix. + const pal = settings.terrainPalettes.find( + (p) => p.name === settings.maps[0].paletteName, + ); + if (pal) { + const oceanIdx = pal.terrains.findIndex((t) => t.name === "ocean"); + if (oceanIdx >= 0) { + pal.terrains.push({ name: "ocean", color: "#888888" }); + } + } + + return settings; +} + +// ─── Inline parser for the metadata cache ────────────────────────────────── + +function parseFileCache(content: string): ParsedCache { + const out: ParsedCache = { + frontmatter: {}, + headings: [], + links: [], + blocks: {}, + }; + + // Frontmatter + let bodyStart = 0; + const fmMatch = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content); + if (fmMatch) { + bodyStart = fmMatch[0].length; + const fmBody = fmMatch[1]; + for (const line of fmBody.split(/\r?\n/)) { + const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line); + if (m) { + const key = m[1]; + let val: unknown = m[2].trim(); + // Strip optional surrounding quotes + if (typeof val === "string") { + const unq = /^"(.*)"$/.exec(val)?.[1]; + if (unq !== undefined) val = unq; + } + out.frontmatter[key] = val; + } + } + } + + // Headings — track byte offsets in the original content + const headingRe = /^(#{1,6})\s+(.+?)\s*$/gm; + let hm: RegExpExecArray | null; + while ((hm = headingRe.exec(content)) !== null) { + if (hm.index < bodyStart) continue; + out.headings.push({ + heading: hm[2], + level: hm[1].length, + position: { + start: { line: 0, col: 0, offset: hm.index }, + end: { line: 0, col: 0, offset: hm.index + hm[0].length }, + }, + }); + } + + // Wikilinks + const linkRe = /\[\[([^\]]+)\]\]/g; + let lm: RegExpExecArray | null; + while ((lm = linkRe.exec(content)) !== null) { + out.links.push({ + link: lm[1].split("|")[0].split("#")[0].trim(), + position: { + start: { line: 0, col: 0, offset: lm.index }, + end: { line: 0, col: 0, offset: lm.index + lm[0].length }, + }, + }); + } + + return out; +} + +// Export a TFile alias as `TFile` for code that does `instanceof TFile`. +// Use a Proxy/typing trick: the orchestrators import `TFile` from "obsidian" +// (the mock obsidian.ts), so we don't substitute here. Test files using these +// mocks should pass MockTFile instances which extend nothing — orchestrators' +// `instanceof TFile` checks will fail. +// +// To make `instanceof TFile` work in orchestrators run through tsx, the test +// setup needs to alias MockTFile under the same name as the obsidian module's +// TFile. The simplest pattern: monkey-patch the imported TFile via a setup +// helper. +import * as obsidian from "obsidian"; +const ObsidianTFile = (obsidian as unknown as { TFile: typeof MockTFile }).TFile; +// Force MockTFile to be reported as instanceof obsidian's TFile. +Object.setPrototypeOf(MockTFile.prototype, ObsidianTFile.prototype); diff --git a/tests/hexGeometry.test.ts b/tests/hexGeometry.test.ts new file mode 100644 index 0000000..f835273 --- /dev/null +++ b/tests/hexGeometry.test.ts @@ -0,0 +1,138 @@ +import { describe, it } from "node:test"; +import expect from "expect"; +import { + hexSize, + hexCenter, + hexPolygonPoints, + gridBoundingBox, +} from "../src/hex-map/hexGeometry"; + +const SQRT3 = Math.sqrt(3); +const close = (a: number, b: number, eps = 1e-6) => Math.abs(a - b) <= eps; + +describe("hexSize", () => { + it("flat-top hex is 2R wide and √3·R tall", () => { + const { w, h } = hexSize(10, "flat"); + expect(w).toBeCloseTo(20); + expect(h).toBeCloseTo(10 * SQRT3); + }); + + it("pointy-top hex is √3·R wide and 2R tall", () => { + const { w, h } = hexSize(10, "pointy"); + expect(w).toBeCloseTo(10 * SQRT3); + expect(h).toBeCloseTo(20); + }); +}); + +describe("hexCenter — flat-top", () => { + it("places (0,0) at (R, h/2)", () => { + const c = hexCenter(0, 0, "flat", 10); + expect(c.cx).toBeCloseTo(10); + expect(c.cy).toBeCloseTo((10 * SQRT3) / 2); + }); + + it("advances col by 1.5R", () => { + const a = hexCenter(0, 0, "flat", 10); + const b = hexCenter(1, 0, "flat", 10); + expect(b.cx - a.cx).toBeCloseTo(15); + }); + + it("advances row by √3·R for non-shifted columns", () => { + const a = hexCenter(0, 0, "flat", 10); + const b = hexCenter(0, 1, "flat", 10); + expect(b.cy - a.cy).toBeCloseTo(10 * SQRT3); + }); + + it("shifts odd columns DOWN by h/2 under odd-stagger", () => { + const even = hexCenter(0, 0, "flat", 10); + const odd = hexCenter(1, 0, "flat", 10, "odd"); + expect(odd.cy - even.cy).toBeCloseTo((10 * SQRT3) / 2); + }); + + it("shifts even columns DOWN under even-stagger", () => { + const even = hexCenter(0, 0, "flat", 10, "even"); + const odd = hexCenter(1, 0, "flat", 10, "even"); + // Now even cols are shifted: col 0 (even) is shifted by h/2, col 1 (odd) isn't + expect(even.cy).toBeCloseTo((10 * SQRT3) / 2 + (10 * SQRT3) / 2); + expect(odd.cy).toBeCloseTo((10 * SQRT3) / 2); + }); +}); + +describe("hexCenter — pointy-top", () => { + it("places (0,0) at (w/2, R)", () => { + const c = hexCenter(0, 0, "pointy", 10); + expect(c.cx).toBeCloseTo((10 * SQRT3) / 2); + expect(c.cy).toBeCloseTo(10); + }); + + it("advances col by √3·R", () => { + const a = hexCenter(0, 0, "pointy", 10); + const b = hexCenter(1, 0, "pointy", 10); + expect(b.cx - a.cx).toBeCloseTo(10 * SQRT3); + }); + + it("advances row by 1.5R", () => { + const a = hexCenter(0, 0, "pointy", 10); + const b = hexCenter(0, 1, "pointy", 10); + expect(b.cy - a.cy).toBeCloseTo(15); + }); + + it("shifts odd rows RIGHT by w/2 under odd-stagger", () => { + const even = hexCenter(0, 0, "pointy", 10); + const odd = hexCenter(0, 1, "pointy", 10, "odd"); + expect(odd.cx - even.cx).toBeCloseTo((10 * SQRT3) / 2); + }); +}); + +describe("hexPolygonPoints", () => { + it("returns six vertices", () => { + expect(hexPolygonPoints(0, 0, "flat", 10)).toHaveLength(6); + }); + + it("flat-top: first vertex is to the right of centre", () => { + const pts = hexPolygonPoints(0, 0, "flat", 10); + expect(pts[0].x).toBeCloseTo(10); + expect(pts[0].y).toBeCloseTo(0); + }); + + it("pointy-top: first vertex is upper-right of centre", () => { + const pts = hexPolygonPoints(0, 0, "pointy", 10); + expect(pts[0].x).toBeCloseTo((10 * SQRT3) / 2); + expect(pts[0].y).toBeCloseTo(5); + }); + + it("all six vertices lie on a circle of radius R around the centre", () => { + const pts = hexPolygonPoints(100, 50, "flat", 25); + for (const p of pts) { + const dx = p.x - 100; + const dy = p.y - 50; + expect(close(Math.hypot(dx, dy), 25)).toBe(true); + } + }); + + it("opposite vertices (i and i+3) are diametrically opposite", () => { + const pts = hexPolygonPoints(0, 0, "flat", 10); + for (let i = 0; i < 3; i++) { + expect(pts[i].x + pts[i + 3].x).toBeCloseTo(0); + expect(pts[i].y + pts[i + 3].y).toBeCloseTo(0); + } + }); +}); + +describe("gridBoundingBox", () => { + it("a single flat-top hex grid is at least (2R wide, √3·R tall)", () => { + const { width, height } = gridBoundingBox(1, 1, "flat", 10); + expect(width).toBeGreaterThanOrEqual(20); + expect(height).toBeGreaterThanOrEqual(Math.ceil(10 * SQRT3)); + }); + + it("scales sensibly for a 10×10 grid", () => { + const { width, height } = gridBoundingBox(10, 10, "flat", 20); + // Width should be roughly 2R + 9 * 1.5R = 2*20 + 9*30 = 310 + expect(width).toBeGreaterThan(300); + expect(width).toBeLessThan(330); + // Height should be roughly 10 * (√3·R) + h/2 stagger = 10 * 34.64 + 17.3 ≈ 364 + expect(height).toBeGreaterThan(330); + expect(height).toBeLessThan(400); + }); +}); diff --git a/tests/integration/e2e-mapPdf.integration.ts b/tests/integration/e2e-mapPdf.integration.ts new file mode 100644 index 0000000..e84e3ee --- /dev/null +++ b/tests/integration/e2e-mapPdf.integration.ts @@ -0,0 +1,130 @@ +/** + * End-to-end integration test for the map → PDF builder (1.4). + * Drives the real `buildMapPdfMarkdown` against the simulated vault to + * exercise the section subdivision + per-section hex row collection + + * `computeSubdivisions` / `enumerateSections` together. + * + * The actual PDF + PNG rendering pipeline is tested separately by the + * chromium-driver integration test. + */ + +import { describe, it, before, after } from "node:test"; +import expect from "expect"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; + +import { MockHexmakerPlugin } from "../helpers/simVault"; +import { + buildMapPdfMarkdown, + computeSubdivisions, + enumerateSections, +} from "../../src/export/exporters/mapWithTable"; + +let tmpDir: string; +let plugin: MockHexmakerPlugin; + +before(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "duckmage-e2e-map-")); + plugin = new MockHexmakerPlugin(tmpDir); +}); + +after(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe("e2e: map → PDF builder against sim-vault", () => { + it("produces a 1×1 layout for the small 3×2 sim-vault map", () => { + // 3×2 grid fits well within any sensible page area at minHexPxOnPage = 60. + const layout = computeSubdivisions({ + gridCols: 3, + gridRows: 2, + pageWidthPx: 950, + pageHeightPx: 360, + minHexPxOnPage: 60, + }); + expect(layout.colSections).toBe(1); + expect(layout.rowSections).toBe(1); + }); + + it("buildMapPdfMarkdown emits cover + TOC + one section page with hex rows", async () => { + const placeholderUri = "data:image/png;base64,AAA"; + const layout = computeSubdivisions({ + gridCols: 3, + gridRows: 2, + pageWidthPx: 950, + pageHeightPx: 360, + minHexPxOnPage: 60, + }); + const sections = enumerateSections(layout, { cols: 3, rows: 2 }); + + const md = await buildMapPdfMarkdown( + plugin as never, + "the-coast", + placeholderUri, + sections, + // One placeholder uri per section (1×1 = 1 section) + sections.map(() => placeholderUri), + ); + + // Cover + expect(md).toMatch(/^# the-coast/m); + expect(md).toMatch(/duckmage-pdf-fullmap/); + + // TOC table + expect(md).toMatch(/## Section index/); + expect(md).toMatch(/\| Section \| Hex range/); + expect(md).toMatch(/\*\*A1\*\*/); + + // Section page heading + cropped image + reference table header + expect(md).toMatch(/## Section A1/); + expect(md).toMatch(/duckmage-pdf-sectionmap/); + expect(md).toMatch( + /\| Hex \| Terrain \| Towns \| Dungeons \| Features \| Quests \| Factions \| Encounters \| Description \|/, + ); + + // All six hexes appear — even 2,0 (only terrain: mountain) and 2,1 (only + // terrain: hills) get a row because terrain alone counts as "populated". + expect(md).toMatch(/\| 0,0 \| shallows/); + expect(md).toMatch(/\| 1,0 \| forest/); + expect(md).toMatch(/\| 0,1 \| ocean/); + expect(md).toMatch(/\| 1,1 \| grass/); + expect(md).toMatch(/\| 2,0 \| mountain/); + expect(md).toMatch(/\| 2,1 \| hills/); + + // Notable cell content (joined basenames) + expect(md).toMatch(/Saltwatch/); + expect(md).toMatch(/The Old Spire/); + expect(md).toMatch(/Standing Stone/); + expect(md).toMatch(/The Reach/); + expect(md).toMatch(/forest-encounters/); + + // Description excerpts (frontmatter not leaked, wikilinks stripped) + expect(md).toMatch(/Calm shallow water/); + expect(md).toMatch(/Dense pine forest/); + }); + + it("the palette duplicate 'ocean' regression: hex 0,1 (ocean) is still in the table", async () => { + // This row would have been silently mis-rendered as `ocean = grey` in the + // old code path. The MD builder doesn't render colors but it DOES look up + // terrain by name when emitting the row; if the duplicate handling were + // wrong, the row's terrain cell could be missing or empty. + const placeholderUri = "data:image/png;base64,AAA"; + const layout = computeSubdivisions({ + gridCols: 3, + gridRows: 2, + pageWidthPx: 950, + pageHeightPx: 360, + minHexPxOnPage: 60, + }); + const sections = enumerateSections(layout, { cols: 3, rows: 2 }); + const md = await buildMapPdfMarkdown( + plugin as never, + "the-coast", + placeholderUri, + sections, + sections.map(() => placeholderUri), + ); + expect(md).toMatch(/\| 0,1 \| ocean \|/); + }); +}); diff --git a/tests/integration/e2e-randomTable.integration.ts b/tests/integration/e2e-randomTable.integration.ts new file mode 100644 index 0000000..2dadc3a --- /dev/null +++ b/tests/integration/e2e-randomTable.integration.ts @@ -0,0 +1,103 @@ +/** + * End-to-end integration test for the random-table exporter (1.1). + * + * Drives the real `exportRandomTableAsPdf` and `exportRandomTableAsMarkdown` + * orchestrators against the simulated vault. Validates the full code path: + * file read → parse → format → render → write — including the linked-notes + * appendix for the `linkedFolder`-driven `treasure` table. + */ + +import { describe, it, before, after } from "node:test"; +import expect from "expect"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { execSync } from "node:child_process"; + +import { MockHexmakerPlugin, MockTFile } from "../helpers/simVault"; +import { + exportRandomTableAsPdf, + exportRandomTableAsMarkdown, +} from "../../src/export/exporters/randomTable"; + +let tmpDir: string; +let plugin: MockHexmakerPlugin; + +before(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "duckmage-e2e-rt-")); + plugin = new MockHexmakerPlugin(tmpDir); +}); + +after(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe("e2e: random-table exporter against sim-vault", () => { + it("exports a basic weighted table to Markdown", async () => { + const file = plugin.app.vault.getAbstractFileByPath( + "tables/forest-encounters.md", + ); + expect(file).toBeTruthy(); + + await exportRandomTableAsMarkdown( + plugin as never, + file as MockTFile as never, + ); + + const out = await fs.readFile( + path.join(tmpDir, "exports/forest-encounters.md"), + "utf8", + ); + expect(out).toMatch(/# forest-encounters/); + expect(out).toMatch(/Roll d6/); + expect(out).toMatch(/Pack of wolves/); + expect(out).toMatch(/Lost merchant/); + expect(out).toMatch(/Bandit ambush/); + expect(out).toMatch(/Druid circle/); + }); + + it("exports a linkedFolder table and inlines each linked note in the appendix", async () => { + const file = plugin.app.vault.getAbstractFileByPath("tables/treasure.md"); + expect(file).toBeTruthy(); + + await exportRandomTableAsMarkdown( + plugin as never, + file as MockTFile as never, + ); + + const out = await fs.readFile( + path.join(tmpDir, "exports/treasure.md"), + "utf8", + ); + + // Table itself + expect(out).toMatch(/# treasure/); + expect(out).toMatch(/gold pile/); + expect(out).toMatch(/silver ring/); + expect(out).toMatch(/cursed amulet/); + + // Linked-notes appendix — each linked note's body should appear + expect(out).toMatch(/<section class="duckmage-export-note/); + expect(out).toMatch(/## gold pile/); + expect(out).toMatch(/heap of unminted gold ingots/); + expect(out).toMatch(/## silver ring/); + expect(out).toMatch(/running stag/); + expect(out).toMatch(/## cursed amulet/); + expect(out).toMatch(/whispers at midnight/); + }); + + it("auto-creates the export folder on first export", async () => { + // Already exercised by the tests above; assert explicitly here so a + // future change that bypasses ensureExportFolder is caught. + const stat = await fs.stat(path.join(tmpDir, "exports")); + expect(stat.isDirectory()).toBe(true); + }); +}); + +// `exportRandomTableAsPdf` is imported but its full PDF pipeline depends on +// Obsidian's MarkdownRenderer + Electron's webview, neither available here. +// The build-markdown step is exercised end-to-end via the Markdown export +// (same path up to the PDF render). PDF rendering is covered by the +// chromium-driver integration tests. +void exportRandomTableAsPdf; +void execSync; diff --git a/tests/integration/e2e-singleHex.integration.ts b/tests/integration/e2e-singleHex.integration.ts new file mode 100644 index 0000000..ba32318 --- /dev/null +++ b/tests/integration/e2e-singleHex.integration.ts @@ -0,0 +1,124 @@ +/** + * End-to-end integration test for the single-hex exporter (1.5). + * Drives the real `exportHexAsMarkdown` + `buildHexMarkdown` against + * the simulated vault. + */ + +import { describe, it, before, after } from "node:test"; +import expect from "expect"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; + +import { MockHexmakerPlugin, MockTFile } from "../helpers/simVault"; +import { + buildHexMarkdown, + exportHexAsMarkdown, +} from "../../src/export/exporters/singleHex"; + +let tmpDir: string; +let plugin: MockHexmakerPlugin; + +before(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "duckmage-e2e-hex-")); + plugin = new MockHexmakerPlugin(tmpDir); +}); + +after(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe("e2e: single-hex exporter against sim-vault", () => { + it("buildHexMarkdown includes every populated section", async () => { + const file = plugin.app.vault.getAbstractFileByPath( + "hexes/the-coast/0_0.md", + ); + expect(file).toBeTruthy(); + const md = await buildHexMarkdown( + plugin as never, + file as MockTFile as never, + { includeGmSections: true }, + ); + + // Title + coord + terrain metadata + expect(md).toMatch(/# 0_0/); + expect(md).toMatch(/Coordinate:\*\* 0, 0/); + expect(md).toMatch(/Terrain:\*\* shallows/); + + // Every populated section + expect(md).toMatch(/## Description/); + expect(md).toMatch(/## Landmark/); + expect(md).toMatch(/## Weather/); + expect(md).toMatch(/## Hooks & Rumors/); + expect(md).toMatch(/## Hidden/); + expect(md).toMatch(/## Secret/); + expect(md).toMatch(/## Towns/); + expect(md).toMatch(/## Dungeons/); + expect(md).toMatch(/## Factions/); + expect(md).toMatch(/## Encounters Table/); + + // Each wrapped in a section block + const blocks = md.match(/<section class="duckmage-hex-section">/g) ?? []; + expect(blocks.length).toBeGreaterThanOrEqual(8); + }); + + it("buildHexMarkdown skips empty sections", async () => { + const file = plugin.app.vault.getAbstractFileByPath( + "hexes/the-coast/1_0.md", + ); + const md = await buildHexMarkdown( + plugin as never, + file as MockTFile as never, + ); + expect(md).toMatch(/## Description/); + expect(md).toMatch(/## Features/); + expect(md).toMatch(/## Quests/); + // 1_0.md has no Towns / Dungeons / Encounters — should NOT appear + expect(md).not.toMatch(/## Towns/); + expect(md).not.toMatch(/## Dungeons/); + expect(md).not.toMatch(/## Encounters Table/); + }); + + it("excludes GM-only sections by default", async () => { + const file = plugin.app.vault.getAbstractFileByPath( + "hexes/the-coast/0_0.md", + ); + const md = await buildHexMarkdown( + plugin as never, + file as MockTFile as never, + ); + // includeGmSections defaults to false + expect(md).not.toMatch(/## Hidden/); + expect(md).not.toMatch(/## Secret/); + // Non-GM sections still present + expect(md).toMatch(/## Description/); + }); + + it("handles a nearly-empty hex without crashing", async () => { + const file = plugin.app.vault.getAbstractFileByPath( + "hexes/the-coast/2_0.md", + ); + const md = await buildHexMarkdown( + plugin as never, + file as MockTFile as never, + ); + // Only terrain frontmatter — title + terrain metadata, no sections + expect(md).toMatch(/# 2_0/); + expect(md).toMatch(/Terrain:\*\* mountain/); + expect(md).not.toMatch(/<section class="duckmage-hex-section">/); + }); + + it("writes the markdown export to the configured folder", async () => { + const file = plugin.app.vault.getAbstractFileByPath( + "hexes/the-coast/1_1.md", + ); + await exportHexAsMarkdown(plugin as never, file as MockTFile as never); + const out = await fs.readFile( + path.join(tmpDir, "exports/1_1.md"), + "utf8", + ); + expect(out).toMatch(/# 1_1/); + expect(out).toMatch(/## Description/); + expect(out).toMatch(/Rolling pastureland/); + }); +}); diff --git a/tests/integration/e2e-singleNote.integration.ts b/tests/integration/e2e-singleNote.integration.ts new file mode 100644 index 0000000..9f67cd9 --- /dev/null +++ b/tests/integration/e2e-singleNote.integration.ts @@ -0,0 +1,65 @@ +/** + * End-to-end integration test for the single-note exporter (1.2). + * Drives the real `exportSingleNoteAsMarkdown` against the simulated vault. + */ + +import { describe, it, before, after } from "node:test"; +import expect from "expect"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; + +import { MockHexmakerPlugin, MockTFile } from "../helpers/simVault"; +import { exportSingleNoteAsMarkdown } from "../../src/export/exporters/singleNote"; + +let tmpDir: string; +let plugin: MockHexmakerPlugin; + +before(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "duckmage-e2e-sn-")); + plugin = new MockHexmakerPlugin(tmpDir); +}); + +after(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe("e2e: single-note exporter against sim-vault", () => { + it("exports a town note with frontmatter stripped and title prepended", async () => { + const file = plugin.app.vault.getAbstractFileByPath( + "towns/Saltwatch.md", + ); + expect(file).toBeTruthy(); + await exportSingleNoteAsMarkdown(plugin as never, file as MockTFile as never); + + const out = await fs.readFile( + path.join(tmpDir, "exports/Saltwatch.md"), + "utf8", + ); + // Title prepended since the source had no leading H1 + expect(out.startsWith("# Saltwatch\n")).toBe(true); + // Frontmatter stripped + expect(out).not.toMatch(/population:/); + // Content preserved + expect(out).toMatch(/A small fishing town/); + expect(out).toMatch(/Maren Kell/); + }); + + it("does not double up the title when the note already has an H1", async () => { + const file = plugin.app.vault.getAbstractFileByPath( + "tables/treasure/gold pile.md", + ); + expect(file).toBeTruthy(); + await exportSingleNoteAsMarkdown(plugin as never, file as MockTFile as never); + + const out = await fs.readFile( + path.join(tmpDir, "exports/gold pile.md"), + "utf8", + ); + // gold pile.md has no H1 → title is prepended + expect(out.startsWith("# gold pile\n")).toBe(true); + // Single h1 only + const h1Count = (out.match(/^# /gm) ?? []).length; + expect(h1Count).toBe(1); + }); +}); diff --git a/tests/integration/e2e-workflow.integration.ts b/tests/integration/e2e-workflow.integration.ts new file mode 100644 index 0000000..ffb8b56 --- /dev/null +++ b/tests/integration/e2e-workflow.integration.ts @@ -0,0 +1,130 @@ +/** + * End-to-end integration test for the workflow exporter (1.6). + * Drives the real `exportWorkflowAsMarkdown` against the simulated vault — + * exercising workflow parse + template load + N-sample rolls + filename. + */ + +import { describe, it, before, after } from "node:test"; +import expect from "expect"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; + +import { MockHexmakerPlugin, MockTFile } from "../helpers/simVault"; +import { + buildWorkflowMarkdown, + exportWorkflowAsMarkdown, +} from "../../src/export/exporters/workflow"; + +let tmpDir: string; +let plugin: MockHexmakerPlugin; + +before(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "duckmage-e2e-wf-")); + plugin = new MockHexmakerPlugin(tmpDir); +}); + +after(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe("e2e: workflow exporter against sim-vault", () => { + it("buildWorkflowMarkdown renders steps + template + samples", async () => { + const file = plugin.app.vault.getAbstractFileByPath( + "workflows/npc-generator.md", + ); + expect(file).toBeTruthy(); + const md = await buildWorkflowMarkdown( + plugin as never, + file as MockTFile as never, + { numSamples: 3 }, + ); + + expect(md).toMatch(/# npc-generator/); + expect(md).toMatch(/Generate a random NPC/); + expect(md).toMatch(/## Steps/); + // npc-names is the linked table (resolved via metadataCache) + expect(md).toMatch(/npc-names/); + expect(md).toMatch(/2d6\+3/); + // Template appears as fenced code + expect(md).toMatch(/## Template/); + expect(md).toMatch(/\$name/); + expect(md).toMatch(/\$hp/); + // 3 samples + expect(md).toMatch(/## Sample outputs/); + const sampleHeadings = md.match(/### Sample \d+/g) ?? []; + expect(sampleHeadings).toHaveLength(3); + }); + + it("each rolled sample picks names from the npc-names fixture table", async () => { + const file = plugin.app.vault.getAbstractFileByPath( + "workflows/npc-generator.md", + ); + const md = await buildWorkflowMarkdown( + plugin as never, + file as MockTFile as never, + { numSamples: 6 }, + ); + // The fixture table has Bromund, Lirel, Vargax, Saskia, Olwen — every + // rolled name must come from that pool. (Probabilistic but with 6 rolls + // we should always see at least one.) + const knownNames = ["Bromund", "Lirel", "Vargax", "Saskia", "Olwen"]; + const anyHit = knownNames.some((n) => md.includes(n)); + expect(anyHit).toBe(true); + }); + + it("toggling includeTemplate / includeRollsBreakdown omits those blocks", async () => { + const file = plugin.app.vault.getAbstractFileByPath( + "workflows/npc-generator.md", + ); + const md = await buildWorkflowMarkdown( + plugin as never, + file as MockTFile as never, + { + numSamples: 1, + includeTemplate: false, + includeRollsBreakdown: false, + }, + ); + expect(md).not.toMatch(/## Template/); + // No "| Step | Label | Roll | Value |" breakdown row in any sample + expect(md).not.toMatch(/\| Step \| Label \| Roll \| Value \|/); + // But the sample heading + filled-template still present + expect(md).toMatch(/### Sample 1/); + expect(md).toMatch(/traveller with/); + }); + + it("numSamples: 0 omits the Sample outputs section entirely", async () => { + const file = plugin.app.vault.getAbstractFileByPath( + "workflows/npc-generator.md", + ); + const md = await buildWorkflowMarkdown( + plugin as never, + file as MockTFile as never, + { numSamples: 0 }, + ); + expect(md).not.toMatch(/## Sample outputs/); + expect(md).not.toMatch(/### Sample \d+/); + // Steps + Template still present + expect(md).toMatch(/## Steps/); + expect(md).toMatch(/## Template/); + }); + + it("writes the markdown export to the configured folder", async () => { + const file = plugin.app.vault.getAbstractFileByPath( + "workflows/npc-generator.md", + ); + await exportWorkflowAsMarkdown( + plugin as never, + file as MockTFile as never, + { numSamples: 2 }, + ); + const out = await fs.readFile( + path.join(tmpDir, "exports/npc-generator.md"), + "utf8", + ); + expect(out).toMatch(/# npc-generator/); + expect(out).toMatch(/### Sample 1/); + expect(out).toMatch(/### Sample 2/); + }); +}); diff --git a/tests/integration/mapWithTableExport.integration.ts b/tests/integration/mapWithTableExport.integration.ts new file mode 100644 index 0000000..9da7ffd --- /dev/null +++ b/tests/integration/mapWithTableExport.integration.ts @@ -0,0 +1,260 @@ +/** + * Integration test for the map → PDF with reference table pipeline (Phase 1.4). + * + * What this validates: + * - A markdown document with a heading + embedded image + reference table + * renders correctly through the chromium-driver pipeline + * - The image renders within the constrained 4in max-height (no overflow) + * - All table rows appear in the rendered PDF text + * + * We synthesize the markdown directly rather than driving the full + * orchestrator (which requires Obsidian's plugin/vault stack). The pure + * builder logic is covered by tests/mapWithTableExport.test.ts; this test + * proves the rendered PDF is correctly assembled. + */ + +import { describe, it, before, after } from "node:test"; +import expect from "expect"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { execSync } from "node:child_process"; + +import { PRINT_PATCH_CSS, wrapInPrintScaffold, escapeHtml } from "../../src/export/printScaffold"; + +const CHROMIUM_DRIVER = "/mnt/c/Users/markr/work/tools/chromium-driver"; + +let tmpDir: string; + +before(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "duckmage-map-itest-")); +}); + +after(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe("map → PDF with reference table — integration", () => { + it("renders cover + TOC + section pages with cropped maps and full reference tables", async () => { + // Two synthetic SVG placeholders — one for the full map, one for each + // section view. The orchestrator renders separate PNGs per section in + // production; we use a different colour per "section" so the rendered + // PDF visibly distinguishes them. + const fullSvg = + `<svg xmlns="http://www.w3.org/2000/svg" width="900" height="640">` + + `<rect width="900" height="640" fill="#2a3540"/>` + + `<text x="450" y="320" text-anchor="middle" font-family="sans-serif" ` + + `font-size="36" fill="#cbd5e1">[ full map placeholder ]</text></svg>`; + const sectionASvg = + `<svg xmlns="http://www.w3.org/2000/svg" width="700" height="320">` + + `<rect width="700" height="320" fill="#3b5c7a"/>` + + `<text x="350" y="160" text-anchor="middle" font-family="sans-serif" ` + + `font-size="28" fill="#fff">[ section A1 placeholder ]</text></svg>`; + const sectionBSvg = sectionASvg.replace("A1", "B1").replace("#3b5c7a", "#5c7a3b"); + + const fullUri = `data:image/svg+xml;base64,${Buffer.from(fullSvg).toString("base64")}`; + const aUri = `data:image/svg+xml;base64,${Buffer.from(sectionASvg).toString("base64")}`; + const bUri = `data:image/svg+xml;base64,${Buffer.from(sectionBSvg).toString("base64")}`; + + // Mirrors the orchestrator's output: cover, TOC, two section pages. + const md = [ + `# the-coast`, + ``, + `<img src="${fullUri}" alt="the-coast full map" class="duckmage-pdf-fullmap">`, + ``, + `<div class="duckmage-pdf-pagebreak"></div>`, + ``, + `## Section index`, + ``, + `| Section | Hex range (col, row) |`, + `| --- | --- |`, + `| **A1** | (0, 0) — (2, 1) |`, + `| **B1** | (3, 0) — (5, 1) |`, + ``, + `<div class="duckmage-pdf-pagebreak"></div>`, + ``, + `## Section A1`, + ``, + `<img src="${aUri}" alt="A1" class="duckmage-pdf-sectionmap">`, + ``, + `| Hex | Terrain | Towns | Dungeons | Features | Quests | Factions | Encounters | Description |`, + `| --- | --- | --- | --- | --- | --- | --- | --- | --- |`, + `| 0,0 | grass | Saltwatch | | Standing Stones | | | | A small fishing town on the coast. |`, + `| 1,0 | forest | | Old Spire | Hidden Ruin | | | Forest - Encounters | Dense woods. |`, + `| 2,0 | mountain | | Goblin Lair | | | The Reach | | Steep cliffs rise sharply. |`, + ``, + `<div class="duckmage-pdf-pagebreak"></div>`, + ``, + `## Section B1`, + ``, + `<img src="${bUri}" alt="B1" class="duckmage-pdf-sectionmap">`, + ``, + `| Hex | Terrain | Towns | Dungeons | Features | Quests | Factions | Encounters | Description |`, + `| --- | --- | --- | --- | --- | --- | --- | --- | --- |`, + `| 3,0 | shallows | | | | | | | Calm blue water. |`, + `| 4,0 | grass | | | | Lost Caravan | The Reach | | Pastureland. |`, + `| 5,0 | hills | | | | | | | Sheep country. |`, + ].join("\n"); + + const html = wrapInFullHtml(md, "the-coast"); + const pdfPath = path.join(tmpDir, "map-with-table.pdf"); + + const { htmlToPdf } = await import(`${CHROMIUM_DRIVER}/scripts/html-to-pdf.mjs`); + await htmlToPdf(html, pdfPath, { pageSize: "Letter", landscape: true }); + + const info = execSync(`pdfinfo "${pdfPath}"`).toString(); + expect(info).toMatch(/PDF version:/); + // Cover + TOC + 2 section pages = 4 pages + const pages = parseInt(/Pages:\s+(\d+)/.exec(info)?.[1] ?? "1", 10); + expect(pages).toBeGreaterThanOrEqual(4); + + const text = execSync(`pdftotext "${pdfPath}" -`).toString(); + const flat = text.replace(/\s+/g, " "); + + // Cover title + TOC + section headings + expect(text).toMatch(/the-coast/); + expect(text).toMatch(/Section index/); + expect(text).toMatch(/Section A1/); + expect(text).toMatch(/Section B1/); + + // Per-section content + expect(flat).toMatch(/Saltwatch/); + expect(flat).toMatch(/Goblin Lair/); + expect(flat).toMatch(/Forest - Encounters/); + expect(flat).toMatch(/Standing Stones/); + expect(flat).toMatch(/Lost Caravan/); + expect(flat).toMatch(/The Reach/); + expect(flat).toMatch(/fishing town/); + expect(flat).toMatch(/Calm blue water/); + expect(flat).toMatch(/Sheep country/); + + // Persist for visual inspection + const pngStem = path.join(tmpDir, "page"); + execSync(`pdftoppm -png -r 96 "${pdfPath}" "${pngStem}"`); + const persistPath = path.resolve(__dirname, "../../.integration-out"); + await fs.mkdir(persistPath, { recursive: true }); + await fs.copyFile(pdfPath, path.join(persistPath, "map-with-table.pdf")); + for (const f of await fs.readdir(tmpDir)) { + if (f.startsWith("page-") && f.endsWith(".png")) { + await fs.copyFile( + path.join(tmpDir, f), + path.join(persistPath, `map-with-table-${f.slice("page-".length)}`), + ); + } + } + }); +}); + +// ─── helpers shared with other integration tests ─────────────────────────── + +function wrapInFullHtml(markdown: string, title: string): string { + const inner = miniMarkdownToHtml(markdown); + const body = wrapInPrintScaffold(title, inner); + return `<!DOCTYPE html> +<html><head><meta charset="utf-8"><title>${escapeHtml(title)} + +${body}`; +} + +function miniMarkdownToHtml(md: string): string { + const lines = md.split("\n"); + const out: string[] = []; + let inTable = false; + let headerEmitted = false; + let inList = false; + const closeTable = () => { + if (inTable) { + out.push(""); + inTable = false; + headerEmitted = false; + } + }; + const closeList = () => { + if (inList) { + out.push(""); + inList = false; + } + }; + const closeAll = () => { + closeTable(); + closeList(); + }; + for (const rawLine of lines) { + const line = rawLine.replace(/\r$/, ""); + if (line.startsWith("### ")) { + closeAll(); + out.push(`

${escapeHtml(line.slice(4))}

`); + continue; + } + if (line.startsWith("## ")) { + closeAll(); + out.push(`

${escapeHtml(line.slice(3))}

`); + continue; + } + if (line.startsWith("# ")) { + closeAll(); + out.push(`

${escapeHtml(line.slice(2))}

`); + continue; + } + if (/^-{3,}\s*$/.test(line)) { + closeAll(); + out.push("
"); + continue; + } + if (line.startsWith("<")) { + closeAll(); + out.push(line); + continue; + } + if (/^[-*] /.test(line)) { + closeTable(); + if (!inList) { + out.push("
    "); + inList = true; + } + out.push(`
  • ${renderInline(line.slice(2))}
  • `); + continue; + } + if (line.trim() === "") { + closeAll(); + continue; + } + if (line.startsWith("|")) { + closeList(); + const cells = line.split("|").slice(1, -1).map((c) => c.trim()); + if (cells.every((c) => /^[\-:]+$/.test(c))) continue; + if (!inTable) { + out.push(""); + inTable = true; + } + if (!headerEmitted) { + out.push("" + cells.map((c) => ``).join("") + ""); + out.push(""); + headerEmitted = true; + } else { + out.push("" + cells.map((c) => ``).join("") + ""); + } + continue; + } + closeAll(); + out.push(`

    ${renderInline(line)}

    `); + } + closeAll(); + return out.join("\n"); +} + +function renderInline(s: string): string { + return s + .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => + `${escapeHtml(alt)}`, + ) + .split(/(]*>)/) + .map((chunk) => { + if (chunk.startsWith("$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/\\\|/g, "|"); + }) + .join(""); +} diff --git a/tests/integration/randomTableExport.integration.ts b/tests/integration/randomTableExport.integration.ts new file mode 100644 index 0000000..87aa75c --- /dev/null +++ b/tests/integration/randomTableExport.integration.ts @@ -0,0 +1,405 @@ +/** + * Integration test for the random-table PDF export pipeline. + * + * What this tests (Layer 4 in the testing-web-frontend taxonomy): + * - parseRandomTable correctly reads the fixture + * - formatRandomTableMarkdown produces the expected markdown + * - Our printScaffold CSS produces a valid PDF via Chromium + * - All entry results appear in the rendered PDF text + * - Page 1 renders as a PNG we can visually inspect + * + * What this does NOT test (would need Obsidian runtime): + * - Obsidian's MarkdownRenderer.render() — we substitute a minimal + * md→html converter for the table subset we generate + * - The user's installed theme inheritance (document.styleSheets capture) + * - The webview lifecycle inside Obsidian's renderer process + * + * Engine note: Chromium's printToPDF (driven via Puppeteer in + * chromium-driver) is the same engine Obsidian's .printToPDF uses. + * Bit-equivalent output for the same HTML + CSS. + */ + +import { describe, it, before, after } from "node:test"; +import expect from "expect"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { execSync } from "node:child_process"; + +import { parseRandomTable } from "../../src/random-tables/randomTable"; +import { + formatRandomTableMarkdown, + formatLinkedNotesAppendix, + type LinkedNote, +} from "../../src/export/exporters/randomTable"; +import { PRINT_PATCH_CSS, wrapInPrintScaffold, escapeHtml } from "../../src/export/printScaffold"; + +const CHROMIUM_DRIVER = "/mnt/c/Users/markr/work/tools/chromium-driver"; +const FIXTURE = path.resolve(__dirname, "../fixtures/sample-random-table.md"); + +let tmpDir: string; + +before(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "duckmage-export-itest-")); +}); + +after(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe("random table PDF export — integration", () => { + it("produces a valid PDF that contains every entry", async () => { + const md = await formatFixture(); + const html = wrapInFullHtml(md, "Sample wandering monsters"); + const pdfPath = path.join(tmpDir, "sample.pdf"); + + const { htmlToPdf } = await import(`${CHROMIUM_DRIVER}/scripts/html-to-pdf.mjs`); + const result = await htmlToPdf(html, pdfPath, { pageSize: "Letter" }); + + // 1. File created with non-trivial size + expect(result.sizeBytes).toBeGreaterThan(3000); + + // 2. pdfinfo confirms it is a valid PDF + const info = execSync(`pdfinfo "${pdfPath}"`).toString(); + expect(info).toMatch(/PDF version:/); + expect(info).toMatch(/Pages:\s+\d+/); + + // 3. All fixture entries appear in extracted text + const text = execSync(`pdftotext "${pdfPath}" -`).toString(); + const expectedEntries = [ + "Pack of wolves", + "Lost merchant", + "Bandit ambush", + "Druid circle", + "Wild boar", + "Travelling minstrel", + "Goblin scouts", + ]; + for (const entry of expectedEntries) { + expect(text).toMatch(new RegExp(entry)); + } + + // 4. Roll-die line present (dice: 20 in frontmatter) + expect(text).toMatch(/Roll d20/); + }); + + it("renders a known-good PNG of page 1 for visual inspection", async () => { + const md = await formatFixture(); + const html = wrapInFullHtml(md, "Sample wandering monsters"); + const pdfPath = path.join(tmpDir, "sample.pdf"); + + const { htmlToPdf } = await import(`${CHROMIUM_DRIVER}/scripts/html-to-pdf.mjs`); + await htmlToPdf(html, pdfPath, { pageSize: "Letter" }); + + const pngStem = path.join(tmpDir, "page"); + execSync(`pdftoppm -png -r 96 -f 1 -l 1 "${pdfPath}" "${pngStem}"`); + const pngPath = `${pngStem}-1.png`; + const stat = await fs.stat(pngPath); + expect(stat.isFile()).toBe(true); + expect(stat.size).toBeGreaterThan(5000); + + // Keep last-run output for ad-hoc visual inspection. + const persistPath = path.resolve(__dirname, "../../.integration-out"); + await fs.mkdir(persistPath, { recursive: true }); + await fs.copyFile(pngPath, path.join(persistPath, "sample-random-table-page1.png")); + await fs.copyFile(pdfPath, path.join(persistPath, "sample-random-table.pdf")); + }); +}); + +describe("random table PDF export with linked notes — integration", () => { + it("includes each linked note's content in the rendered PDF, in entry order", async () => { + // Synthesize the same markdown the runtime would produce when 3 entries + // resolve to vault notes. We bypass the vault lookup since this test runs + // outside Obsidian — the formatters and the rendering pipeline are what + // we're validating here. + const baseMd = await formatFixture(); + const linkedNotes: LinkedNote[] = [ + { + name: "Pack of wolves", + content: + "A pack of **6–10 grey wolves** stalking the party. They strike at dusk and fade into the trees if injured.", + }, + { + name: "Bandit ambush", + content: + "A roadside ambush by *desperate* highway robbers. They demand coin first; fight only if pushed.", + }, + { + name: "Druid circle", + content: + "A solemn gathering of three druids in a moonlit clearing. They speak the language of birds.", + }, + ]; + const appendix = formatLinkedNotesAppendix(linkedNotes); + expect(appendix.length).toBeGreaterThan(0); + const fullMd = `${baseMd}\n\n${appendix}`; + const html = wrapInFullHtml(fullMd, "Sample wandering monsters (with linked notes)"); + + const pdfPath = path.join(tmpDir, "sample-with-notes.pdf"); + const { htmlToPdf } = await import(`${CHROMIUM_DRIVER}/scripts/html-to-pdf.mjs`); + await htmlToPdf(html, pdfPath, { pageSize: "Letter" }); + + // PDF valid + has multiple pages OR enough content + const info = execSync(`pdfinfo "${pdfPath}"`).toString(); + expect(info).toMatch(/PDF version:/); + + const text = execSync(`pdftotext "${pdfPath}" -`).toString(); + + // Table entries (from base) still present + expect(text).toMatch(/Pack of wolves/); + expect(text).toMatch(/Bandit ambush/); + expect(text).toMatch(/Druid circle/); + + // Linked-note content present. Use a normalized form that collapses + // whitespace (pdftotext line-wraps inside paragraphs). + const flat = text.replace(/\s+/g, " "); + expect(flat).toMatch(/grey wolves/); + expect(flat).toMatch(/highway robbers/); + expect(flat).toMatch(/moonlit clearing/); + expect(flat).toMatch(/language of birds/); + + // Entry order preserved in PDF text + const idxWolves = flat.indexOf("grey wolves"); + const idxBandits = flat.indexOf("highway robbers"); + const idxDruids = flat.indexOf("moonlit clearing"); + expect(idxWolves).toBeGreaterThan(-1); + expect(idxBandits).toBeGreaterThan(idxWolves); + expect(idxDruids).toBeGreaterThan(idxBandits); + + // Persist for visual inspection + const pngStem = path.join(tmpDir, "page-notes"); + execSync(`pdftoppm -png -r 96 -f 1 -l 2 "${pdfPath}" "${pngStem}"`); + const persistPath = path.resolve(__dirname, "../../.integration-out"); + await fs.mkdir(persistPath, { recursive: true }); + await fs.copyFile(pdfPath, path.join(persistPath, "sample-with-linked-notes.pdf")); + // Copy whatever page PNGs were produced. + for (const pageFile of await fs.readdir(tmpDir)) { + if (pageFile.startsWith("page-notes-") && pageFile.endsWith(".png")) { + await fs.copyFile( + path.join(tmpDir, pageFile), + path.join(persistPath, `sample-with-linked-notes-${pageFile.slice("page-notes-".length)}`), + ); + } + } + }); + + it("emits no appendix when no entries resolve", () => { + expect(formatLinkedNotesAppendix([])).toBe(""); + }); + + // Stress test: long notes + an oversized image. Without proper CSS, this + // exposes (a) images blowing past the page width and (b) section headings + // becoming orphaned at the bottom of a page with their content on the next. + it("keeps headings with content and constrains image size on multi-page output", async () => { + const baseMd = await formatFixture(); + + // A 1200x900 SVG — deliberately larger than the printable column width + // (Letter at 0.6" left + 0.6" right margin ≈ 7.3" wide ≈ 700px @ 96dpi). + // If the CSS doesn't constrain it, the image will overflow horribly. + const bigImgSvgRaw = + `` + + `` + + `` + + `` + + `Oversized test image (1200x900)`; + const bigImgUrl = `data:image/svg+xml;base64,${Buffer.from(bigImgSvgRaw).toString("base64")}`; + + const longPara = (n: number) => + `Paragraph ${n} of an intentionally long encounter writeup. ` + + `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor ` + + `incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud ` + + `exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.`; + + const linkedNotes: LinkedNote[] = [ + { + name: "Pack of wolves", + content: + "A pack of **6–10 grey wolves** stalking the party. They strike at dusk and fade into the trees if injured.", + }, + { + name: "Lost merchant", + content: + `![A weathered map of the forest road](${bigImgUrl})\n\n` + + "A travelling cloth merchant separated from a caravan. Knows the way back to town and will pay for an escort.", + }, + { + name: "Bandit ambush", + content: + "A roadside ambush by *desperate* highway robbers. They demand coin first; fight only if pushed.\n\n" + + longPara(1) + + "\n\n" + + longPara(2) + + "\n\n" + + longPara(3) + + "\n\n" + + longPara(4) + + "\n\n" + + longPara(5), + }, + { + name: "Druid circle", + content: + "A solemn gathering of three druids in a moonlit clearing. They speak the language of birds.", + }, + { + name: "Wild boar", + content: + "A territorial **boar** charges from undergrowth. Hardy and aggressive. " + + longPara(6) + + "\n\n" + + longPara(7), + }, + ]; + const appendix = formatLinkedNotesAppendix(linkedNotes); + const fullMd = `${baseMd}\n\n${appendix}`; + const html = wrapInFullHtml(fullMd, "Pagination stress test"); + + const pdfPath = path.join(tmpDir, "pagination-stress.pdf"); + const { htmlToPdf } = await import(`${CHROMIUM_DRIVER}/scripts/html-to-pdf.mjs`); + await htmlToPdf(html, pdfPath, { pageSize: "Letter" }); + + const info = execSync(`pdfinfo "${pdfPath}"`).toString(); + const pages = parseInt(/Pages:\s+(\d+)/.exec(info)?.[1] ?? "1", 10); + // Multi-page output expected — that's the whole point of this test. + expect(pages).toBeGreaterThan(1); + + // Render every page to PNG for visual inspection. + const pngStem = path.join(tmpDir, "stress"); + execSync(`pdftoppm -png -r 96 "${pdfPath}" "${pngStem}"`); + + const persistPath = path.resolve(__dirname, "../../.integration-out"); + await fs.mkdir(persistPath, { recursive: true }); + await fs.copyFile(pdfPath, path.join(persistPath, "pagination-stress.pdf")); + for (const pageFile of await fs.readdir(tmpDir)) { + if (pageFile.startsWith("stress-") && pageFile.endsWith(".png")) { + await fs.copyFile( + path.join(tmpDir, pageFile), + path.join( + persistPath, + `pagination-stress-${pageFile.slice("stress-".length)}`, + ), + ); + } + } + }); +}); + +async function formatFixture(): Promise { + const content = await fs.readFile(FIXTURE, "utf8"); + const table = parseRandomTable(content); + return formatRandomTableMarkdown("Sample wandering monsters", table); +} + +/** + * Compose the full HTML document the runtime would render. Substitutes a + * minimal markdown→HTML converter for Obsidian's MarkdownRenderer since this + * test runs outside Obsidian. + */ +function wrapInFullHtml(markdown: string, title: string): string { + const inner = miniMarkdownToHtml(markdown); + const body = wrapInPrintScaffold(title, inner); + return ` +${escapeHtml(title)} + +${body}`; +} + +/** + * Minimal markdown→HTML for the subset our exporter emits: + * - `# heading` + * - `*italic*` + * - markdown tables (`|cell|cell|` with separator row) + * - plain paragraphs + * + * NOT a general markdown renderer. Real runtime uses Obsidian's renderer. + */ +function miniMarkdownToHtml(md: string): string { + const lines = md.split("\n"); + const out: string[] = []; + let inTable = false; + let headerEmitted = false; + + const closeTable = () => { + if (inTable) { + out.push("
    ${renderInline(c)}
    ${renderInline(c)}
    "); + inTable = false; + headerEmitted = false; + } + }; + + for (const rawLine of lines) { + const line = rawLine.replace(/\r$/, ""); + if (line.startsWith("### ")) { + closeTable(); + out.push(`

    ${escapeHtml(line.slice(4))}

    `); + continue; + } + if (line.startsWith("## ")) { + closeTable(); + out.push(`

    ${escapeHtml(line.slice(3))}

    `); + continue; + } + if (line.startsWith("# ")) { + closeTable(); + out.push(`

    ${escapeHtml(line.slice(2))}

    `); + continue; + } + if (/^-{3,}\s*$/.test(line)) { + closeTable(); + out.push("
    "); + continue; + } + // Inline HTML block (e.g.
    ,
    ) — pass through verbatim + // so the wrapping markup the appendix formatter emits survives. + if (line.startsWith("<")) { + closeTable(); + out.push(line); + continue; + } + if (line.trim() === "") { + closeTable(); + continue; + } + if (line.startsWith("|")) { + const cells = line + .split("|") + .slice(1, -1) + .map((c) => c.trim()); + if (cells.every((c) => /^[\-:]+$/.test(c))) continue; // separator row + if (!inTable) { + out.push(""); + inTable = true; + } + if (!headerEmitted) { + out.push("" + cells.map((c) => ``).join("") + ""); + out.push(""); + headerEmitted = true; + } else { + out.push("" + cells.map((c) => ``).join("") + ""); + } + continue; + } + closeTable(); + out.push(`

    ${renderInline(line)}

    `); + } + closeTable(); + return out.join("\n"); +} + +function renderInline(s: string): string { + // Extract image syntax first so its URL doesn't get escaped. + // ![alt](url) — both alt and url + return s + .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => + `${escapeHtml(alt)}`, + ) + .split(/(]*>)/) + .map((chunk) => { + if (chunk.startsWith("$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/\\\|/g, "|"); + }) + .join(""); +} diff --git a/tests/integration/singleHexExport.integration.ts b/tests/integration/singleHexExport.integration.ts new file mode 100644 index 0000000..dc5ddf7 --- /dev/null +++ b/tests/integration/singleHexExport.integration.ts @@ -0,0 +1,313 @@ +/** + * Integration test for the single-hex PDF export (Phase 1.5). + * + * Builds the markdown that the orchestrator would produce from a sample + * hex note, runs it through the chromium-driver pipeline, and asserts + * every section appears in the rendered PDF with appropriate spacing. + */ + +import { describe, it, before, after } from "node:test"; +import expect from "expect"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { execSync } from "node:child_process"; + +import { PRINT_PATCH_CSS, wrapInPrintScaffold, escapeHtml } from "../../src/export/printScaffold"; + +const CHROMIUM_DRIVER = "/mnt/c/Users/markr/work/tools/chromium-driver"; + +let tmpDir: string; + +before(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "duckmage-hex-itest-")); +}); + +after(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe("single-hex PDF export — integration", () => { + it("renders every section with breathing room and includes GM sections when toggled on", async () => { + // Hand-built markdown that mirrors what `buildHexMarkdown` produces for a + // hex note with content in every section AND `includeGmSections: true`. + const md = [ + `# 5_3`, + ``, + `**Coordinate:** 5, 3 `, + `**Terrain:** forest`, + ``, + `
    `, + ``, + `## Description`, + ``, + `The trees lean north against an old wind. A scoured standing stone juts from the moss.`, + ``, + `
    `, + ``, + `
    `, + ``, + `## Landmark`, + ``, + `Pale stone, lichen-mottled, taller than two riders.`, + ``, + `
    `, + ``, + `
    `, + ``, + `## Weather`, + ``, + `Misty mornings; warm afternoons.`, + ``, + `
    `, + ``, + `
    `, + ``, + `## Hooks & Rumors`, + ``, + `- The standing stone hums at midwinter.`, + `- A masked rider was seen two evenings ago.`, + ``, + `
    `, + ``, + `
    `, + ``, + `## Hidden`, + ``, + `A weathered cache beneath the standing stone holds three gold ingots.`, + ``, + `
    `, + ``, + `
    `, + ``, + `## Secret`, + ``, + `The standing stone is one of seven binding a sleeping power.`, + ``, + `
    `, + ``, + `
    `, + ``, + `## Towns`, + ``, + `- [[Saltwatch]]`, + ``, + `
    `, + ``, + `
    `, + ``, + `## Dungeons`, + ``, + `- [[The Old Spire]]`, + `- [[Sunken Crypt]]`, + ``, + `
    `, + ``, + `
    `, + ``, + `## Features`, + ``, + `- [[Standing Stone]]`, + `- [[Forgotten Path]]`, + ``, + `
    `, + ``, + `
    `, + ``, + `## Quests`, + ``, + `- [[Find the Wanderer]]`, + ``, + `
    `, + ``, + `
    `, + ``, + `## Factions`, + ``, + `- [[The Reach]]`, + ``, + `
    `, + ``, + `
    `, + ``, + `## Encounters Table`, + ``, + `- [[Forest - Encounters]]`, + ``, + `
    `, + ].join("\n"); + + const html = wrapInFullHtml(md, "5_3"); + const pdfPath = path.join(tmpDir, "hex.pdf"); + const { htmlToPdf } = await import(`${CHROMIUM_DRIVER}/scripts/html-to-pdf.mjs`); + await htmlToPdf(html, pdfPath, { pageSize: "Letter" }); + + const info = execSync(`pdfinfo "${pdfPath}"`).toString(); + expect(info).toMatch(/PDF version:/); + + const text = execSync(`pdftotext "${pdfPath}" -`).toString(); + const flat = text.replace(/\s+/g, " "); + + // Title + metadata + expect(flat).toMatch(/5_3/); + expect(flat).toMatch(/Coordinate: 5, 3/); + expect(flat).toMatch(/Terrain: forest/); + + // Every section heading + expect(flat).toMatch(/Description/); + expect(flat).toMatch(/Landmark/); + expect(flat).toMatch(/Weather/); + expect(flat).toMatch(/Hooks & Rumors/); + expect(flat).toMatch(/Hidden/); + expect(flat).toMatch(/Secret/); + expect(flat).toMatch(/Towns/); + expect(flat).toMatch(/Dungeons/); + expect(flat).toMatch(/Features/); + expect(flat).toMatch(/Quests/); + expect(flat).toMatch(/Factions/); + expect(flat).toMatch(/Encounters Table/); + + // Content fragments unique to each section + expect(flat).toMatch(/scoured standing stone/); + expect(flat).toMatch(/Pale stone, lichen-mottled/); + expect(flat).toMatch(/Misty mornings/); + expect(flat).toMatch(/hums at midwinter/); + expect(flat).toMatch(/three gold ingots/); + expect(flat).toMatch(/binding a sleeping power/); + expect(flat).toMatch(/Saltwatch/); + expect(flat).toMatch(/The Old Spire/); + expect(flat).toMatch(/Standing Stone/); + expect(flat).toMatch(/Find the Wanderer/); + expect(flat).toMatch(/The Reach/); + expect(flat).toMatch(/Forest - Encounters/); + + // Persist for visual inspection + const pngStem = path.join(tmpDir, "page"); + execSync(`pdftoppm -png -r 96 "${pdfPath}" "${pngStem}"`); + const persistPath = path.resolve(__dirname, "../../.integration-out"); + await fs.mkdir(persistPath, { recursive: true }); + await fs.copyFile(pdfPath, path.join(persistPath, "single-hex.pdf")); + for (const f of await fs.readdir(tmpDir)) { + if (f.startsWith("page-") && f.endsWith(".png")) { + await fs.copyFile( + path.join(tmpDir, f), + path.join(persistPath, `single-hex-${f.slice("page-".length)}`), + ); + } + } + }); +}); + +// ─── helpers ─────────────────────────────────────────────────────────────── + +function wrapInFullHtml(markdown: string, title: string): string { + const inner = miniMarkdownToHtml(markdown); + const body = wrapInPrintScaffold(title, inner); + return ` +${escapeHtml(title)} + +${body}`; +} + +function miniMarkdownToHtml(md: string): string { + const lines = md.split("\n"); + const out: string[] = []; + let inTable = false; + let headerEmitted = false; + let inList = false; + const closeTable = () => { + if (inTable) { + out.push("
    ${renderInline(c)}
    ${renderInline(c)}
    "); + inTable = false; + headerEmitted = false; + } + }; + const closeList = () => { + if (inList) { + out.push("
"); + inList = false; + } + }; + const closeAll = () => { + closeTable(); + closeList(); + }; + for (const rawLine of lines) { + const line = rawLine.replace(/\r$/, ""); + if (line.startsWith("### ")) { + closeAll(); + out.push(`

${escapeHtml(line.slice(4))}

`); + continue; + } + if (line.startsWith("## ")) { + closeAll(); + out.push(`

${escapeHtml(line.slice(3))}

`); + continue; + } + if (line.startsWith("# ")) { + closeAll(); + out.push(`

${escapeHtml(line.slice(2))}

`); + continue; + } + if (/^-{3,}\s*$/.test(line)) { + closeAll(); + out.push("
"); + continue; + } + if (line.startsWith("<")) { + closeAll(); + out.push(line); + continue; + } + if (/^[-*] /.test(line)) { + closeTable(); + if (!inList) { + out.push("
    "); + inList = true; + } + out.push(`
  • ${renderInline(line.slice(2))}
  • `); + continue; + } + if (line.trim() === "") { + closeAll(); + continue; + } + if (line.startsWith("|")) { + closeList(); + const cells = line.split("|").slice(1, -1).map((c) => c.trim()); + if (cells.every((c) => /^[\-:]+$/.test(c))) continue; + if (!inTable) { + out.push(""); + inTable = true; + } + if (!headerEmitted) { + out.push("" + cells.map((c) => ``).join("") + ""); + out.push(""); + headerEmitted = true; + } else { + out.push("" + cells.map((c) => ``).join("") + ""); + } + continue; + } + closeAll(); + out.push(`

    ${renderInline(line)}

    `); + } + closeAll(); + return out.join("\n"); +} + +function renderInline(s: string): string { + return s + .replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_m, target: string, alias: string | undefined) => + alias ? alias : target, + ) + .split(/(]*>)/) + .map((chunk) => { + if (chunk.startsWith("$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/\\\|/g, "|"); + }) + .join(""); +} diff --git a/tests/integration/singleNoteExport.integration.ts b/tests/integration/singleNoteExport.integration.ts new file mode 100644 index 0000000..83331ca --- /dev/null +++ b/tests/integration/singleNoteExport.integration.ts @@ -0,0 +1,209 @@ +/** + * Integration test for the single-note PDF export pipeline (Phase 1.2). + * + * Pipeline: + * read fixture → stripFrontmatter → ensureLeadingTitle → mini md→html → + * wrap in print scaffold → chromium-driver htmlToPdf → pdfinfo + pdftotext + * + * What this validates: + * - Frontmatter is stripped (tags/population shouldn't appear in PDF) + * - Title is the file basename (prepended because the fixture has no H1) + * - All section headings + bullets render + * - Bold/italic survive the pipeline + * + * What it doesn't (would need Obsidian runtime): + * - Theme inheritance from document.styleSheets + * - Wikilink resolution to clickable anchors + */ + +import { describe, it, before, after } from "node:test"; +import expect from "expect"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { execSync } from "node:child_process"; + +import { ensureLeadingTitle } from "../../src/export/exporters/singleNote"; +import { PRINT_PATCH_CSS, wrapInPrintScaffold, escapeHtml } from "../../src/export/printScaffold"; + +const CHROMIUM_DRIVER = "/mnt/c/Users/markr/work/tools/chromium-driver"; +const FIXTURE = path.resolve(__dirname, "../fixtures/sample-town.md"); + +let tmpDir: string; + +before(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "duckmage-single-itest-")); +}); + +after(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe("single note PDF export — integration", () => { + it("renders the fixture cleanly with stripped frontmatter and prepended title", async () => { + const raw = await fs.readFile(FIXTURE, "utf8"); + const md = ensureLeadingTitle(raw, "Saltwatch"); + const html = wrapInFullHtml(md, "Saltwatch"); + + const pdfPath = path.join(tmpDir, "saltwatch.pdf"); + const { htmlToPdf } = await import(`${CHROMIUM_DRIVER}/scripts/html-to-pdf.mjs`); + const result = await htmlToPdf(html, pdfPath, { pageSize: "Letter" }); + expect(result.sizeBytes).toBeGreaterThan(3000); + + const info = execSync(`pdfinfo "${pdfPath}"`).toString(); + expect(info).toMatch(/PDF version:/); + + const text = execSync(`pdftotext "${pdfPath}" -`).toString(); + + // Title (prepended because fixture has no H1) + expect(text).toMatch(/Saltwatch/); + + // Section headings + expect(text).toMatch(/Overview/); + expect(text).toMatch(/Notable Residents/); + expect(text).toMatch(/Hooks & Rumors/); + + // Content fragments unique to each section + expect(text).toMatch(/southern cliffs/); + expect(text).toMatch(/Maren Kell/); + expect(text).toMatch(/lighthouse burns blue/); + + // Frontmatter must NOT leak into the rendered text + expect(text).not.toMatch(/tags:\s*\[/); + expect(text).not.toMatch(/population:\s*320/); + + // Persist for visual inspection + const pngStem = path.join(tmpDir, "page"); + execSync(`pdftoppm -png -r 96 -f 1 -l 1 "${pdfPath}" "${pngStem}"`); + const persistPath = path.resolve(__dirname, "../../.integration-out"); + await fs.mkdir(persistPath, { recursive: true }); + await fs.copyFile(pdfPath, path.join(persistPath, "single-note.pdf")); + await fs.copyFile(`${pngStem}-1.png`, path.join(persistPath, "single-note-page1.png")); + }); + + it("does not prepend a title when the note already begins with H1", () => { + const raw = "# My existing title\n\nSome content."; + expect(ensureLeadingTitle(raw, "Other Name")).toBe(raw); + }); +}); + +// ─── helpers shared with the random-table integration test ───────────────── + +function wrapInFullHtml(markdown: string, title: string): string { + const inner = miniMarkdownToHtml(markdown); + const body = wrapInPrintScaffold(title, inner); + return ` +${escapeHtml(title)} + +${body}`; +} + +/** + * Minimal markdown→HTML for our exporter's emitted subset: + * h1/h2/h3, ul (bullets), tables, paragraphs, ![alt](url), `
    ` blocks. + * NOT a general markdown renderer. + */ +function miniMarkdownToHtml(md: string): string { + const lines = md.split("\n"); + const out: string[] = []; + let inTable = false; + let headerEmitted = false; + let inList = false; + + const closeTable = () => { + if (inTable) { + out.push("
    ${renderInline(c)}
    ${renderInline(c)}
    "); + inTable = false; + headerEmitted = false; + } + }; + const closeList = () => { + if (inList) { + out.push("
"); + inList = false; + } + }; + const closeAll = () => { + closeTable(); + closeList(); + }; + + for (const rawLine of lines) { + const line = rawLine.replace(/\r$/, ""); + if (line.startsWith("### ")) { + closeAll(); + out.push(`

${escapeHtml(line.slice(4))}

`); + continue; + } + if (line.startsWith("## ")) { + closeAll(); + out.push(`

${escapeHtml(line.slice(3))}

`); + continue; + } + if (line.startsWith("# ")) { + closeAll(); + out.push(`

${escapeHtml(line.slice(2))}

`); + continue; + } + if (/^-{3,}\s*$/.test(line)) { + closeAll(); + out.push("
"); + continue; + } + if (line.startsWith("<")) { + closeAll(); + out.push(line); + continue; + } + if (/^[-*] /.test(line)) { + closeTable(); + if (!inList) { + out.push("
    "); + inList = true; + } + out.push(`
  • ${renderInline(line.slice(2))}
  • `); + continue; + } + if (line.trim() === "") { + closeAll(); + continue; + } + if (line.startsWith("|")) { + closeList(); + const cells = line.split("|").slice(1, -1).map((c) => c.trim()); + if (cells.every((c) => /^[\-:]+$/.test(c))) continue; + if (!inTable) { + out.push(""); + inTable = true; + } + if (!headerEmitted) { + out.push("" + cells.map((c) => ``).join("") + ""); + out.push(""); + headerEmitted = true; + } else { + out.push("" + cells.map((c) => ``).join("") + ""); + } + continue; + } + closeAll(); + out.push(`

    ${renderInline(line)}

    `); + } + closeAll(); + return out.join("\n"); +} + +function renderInline(s: string): string { + return s + .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => + `${escapeHtml(alt)}`, + ) + .split(/(]*>)/) + .map((chunk) => { + if (chunk.startsWith("$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/\\\|/g, "|"); + }) + .join(""); +} diff --git a/tests/integration/workflowExport.integration.ts b/tests/integration/workflowExport.integration.ts new file mode 100644 index 0000000..71976fa --- /dev/null +++ b/tests/integration/workflowExport.integration.ts @@ -0,0 +1,271 @@ +/** + * Integration test for the workflow PDF export (Phase 1.6). + * + * Synthesizes the markdown that `buildWorkflowMarkdown` would produce for a + * small workflow with 3 sample outputs, runs it through chromium-driver, and + * asserts the rendered PDF contains the steps table, template, every sample + * heading, the per-sample rolls breakdown, and the filled-in template text. + */ + +import { describe, it, before, after } from "node:test"; +import expect from "expect"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { execSync } from "node:child_process"; + +import { PRINT_PATCH_CSS, wrapInPrintScaffold, escapeHtml } from "../../src/export/printScaffold"; + +const CHROMIUM_DRIVER = "/mnt/c/Users/markr/work/tools/chromium-driver"; + +let tmpDir: string; + +before(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "duckmage-workflow-itest-")); +}); + +after(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe("workflow PDF export — integration", () => { + it("renders the steps table, template, and N sample outputs with breakdowns", async () => { + const md = [ + `# NPC Generator`, + ``, + `Roll a random encounter NPC with a name, occupation, and demeanor.`, + ``, + `## Steps`, + ``, + `| # | Table / Formula | Rolls | Label |`, + `| --- | --- | --- | --- |`, + `| 1 | names/first | 1 | name |`, + `| 2 | occupations | 1 | job |`, + `| 3 | demeanors | 1 | mood |`, + `| 4 | 2d6+3 | 1 | hp |`, + ``, + `## Template`, + ``, + "```", + "$name the $job, a $mood traveller (HP $hp).", + "```", + ``, + `## Sample outputs`, + ``, + `
    `, + ``, + `### Sample 1`, + ``, + `| Step | Label | Roll | Value |`, + `| --- | --- | --- | --- |`, + `| 1 | name | — | Bromund |`, + `| 2 | job | — | tinsmith |`, + `| 3 | mood | — | cheerful |`, + `| 4 | hp | — | (4+5)+3=12 |`, + ``, + `Bromund the tinsmith, a cheerful traveller (HP (4+5)+3=12).`, + ``, + `
    `, + ``, + `
    `, + ``, + `### Sample 2`, + ``, + `| Step | Label | Roll | Value |`, + `| --- | --- | --- | --- |`, + `| 1 | name | — | Lirel |`, + `| 2 | job | — | rope-maker |`, + `| 3 | mood | — | wary |`, + `| 4 | hp | — | (3+2)+3=8 |`, + ``, + `Lirel the rope-maker, a wary traveller (HP (3+2)+3=8).`, + ``, + `
    `, + ``, + `
    `, + ``, + `### Sample 3`, + ``, + `| Step | Label | Roll | Value |`, + `| --- | --- | --- | --- |`, + `| 1 | name | — | Vargax |`, + `| 2 | job | — | charcoal-burner |`, + `| 3 | mood | — | drunk |`, + `| 4 | hp | — | (6+6)+3=15 |`, + ``, + `Vargax the charcoal-burner, a drunk traveller (HP (6+6)+3=15).`, + ``, + `
    `, + ].join("\n"); + + const html = wrapInFullHtml(md, "NPC Generator"); + const pdfPath = path.join(tmpDir, "workflow.pdf"); + const { htmlToPdf } = await import(`${CHROMIUM_DRIVER}/scripts/html-to-pdf.mjs`); + await htmlToPdf(html, pdfPath, { pageSize: "Letter" }); + + const info = execSync(`pdfinfo "${pdfPath}"`).toString(); + expect(info).toMatch(/PDF version:/); + + const text = execSync(`pdftotext "${pdfPath}" -`).toString(); + const flat = text.replace(/\s+/g, " "); + + // Top-level structure + expect(text).toMatch(/NPC Generator/); + expect(text).toMatch(/Roll a random encounter NPC/); + expect(text).toMatch(/Steps/); + expect(text).toMatch(/Template/); + expect(text).toMatch(/Sample outputs/); + + // Steps table fragments + expect(flat).toMatch(/names\/first/); + expect(flat).toMatch(/2d6\+3/); + + // Every sample heading + expect(text).toMatch(/Sample 1/); + expect(text).toMatch(/Sample 2/); + expect(text).toMatch(/Sample 3/); + + // Per-sample rolled values + filled template + expect(flat).toMatch(/Bromund the tinsmith/); + expect(flat).toMatch(/Lirel the rope-maker/); + expect(flat).toMatch(/Vargax the charcoal-burner/); + expect(flat).toMatch(/cheerful traveller/); + + // Persist for visual inspection + const pngStem = path.join(tmpDir, "page"); + execSync(`pdftoppm -png -r 96 "${pdfPath}" "${pngStem}"`); + const persistPath = path.resolve(__dirname, "../../.integration-out"); + await fs.mkdir(persistPath, { recursive: true }); + await fs.copyFile(pdfPath, path.join(persistPath, "workflow.pdf")); + for (const f of await fs.readdir(tmpDir)) { + if (f.startsWith("page-") && f.endsWith(".png")) { + await fs.copyFile( + path.join(tmpDir, f), + path.join(persistPath, `workflow-${f.slice("page-".length)}`), + ); + } + } + }); +}); + +// ─── helpers ─────────────────────────────────────────────────────────────── + +function wrapInFullHtml(markdown: string, title: string): string { + const inner = miniMarkdownToHtml(markdown); + const body = wrapInPrintScaffold(title, inner); + return ` +${escapeHtml(title)} + +${body}`; +} + +function miniMarkdownToHtml(md: string): string { + const lines = md.split("\n"); + const out: string[] = []; + let inTable = false; + let headerEmitted = false; + let inList = false; + let inFence = false; + const fenceBuf: string[] = []; + const closeTable = () => { + if (inTable) { + out.push("
    ${renderInline(c)}
    ${renderInline(c)}
    "); + inTable = false; + headerEmitted = false; + } + }; + const closeList = () => { + if (inList) { + out.push("
"); + inList = false; + } + }; + const closeAll = () => { + closeTable(); + closeList(); + }; + for (const rawLine of lines) { + const line = rawLine.replace(/\r$/, ""); + if (line.startsWith("```")) { + closeAll(); + if (inFence) { + out.push(`
${escapeHtml(fenceBuf.join("\n"))}
`); + fenceBuf.length = 0; + inFence = false; + } else { + inFence = true; + } + continue; + } + if (inFence) { + fenceBuf.push(line); + continue; + } + if (line.startsWith("### ")) { + closeAll(); + out.push(`

${escapeHtml(line.slice(4))}

`); + continue; + } + if (line.startsWith("## ")) { + closeAll(); + out.push(`

${escapeHtml(line.slice(3))}

`); + continue; + } + if (line.startsWith("# ")) { + closeAll(); + out.push(`

${escapeHtml(line.slice(2))}

`); + continue; + } + if (/^-{3,}\s*$/.test(line)) { + closeAll(); + out.push("
"); + continue; + } + if (line.startsWith("<")) { + closeAll(); + out.push(line); + continue; + } + if (/^[-*] /.test(line)) { + closeTable(); + if (!inList) { + out.push("
    "); + inList = true; + } + out.push(`
  • ${renderInline(line.slice(2))}
  • `); + continue; + } + if (line.trim() === "") { + closeAll(); + continue; + } + if (line.startsWith("|")) { + closeList(); + const cells = line.split("|").slice(1, -1).map((c) => c.trim()); + if (cells.every((c) => /^[\-:]+$/.test(c))) continue; + if (!inTable) { + out.push(""); + inTable = true; + } + if (!headerEmitted) { + out.push("" + cells.map((c) => ``).join("") + ""); + out.push(""); + headerEmitted = true; + } else { + out.push("" + cells.map((c) => ``).join("") + ""); + } + continue; + } + closeAll(); + out.push(`

    ${renderInline(line)}

    `); + } + closeAll(); + return out.join("\n"); +} + +function renderInline(s: string): string { + return escapeHtml(s) + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/\\\|/g, "|"); +} diff --git a/tests/mapWithTableExport.test.ts b/tests/mapWithTableExport.test.ts new file mode 100644 index 0000000..5c07935 --- /dev/null +++ b/tests/mapWithTableExport.test.ts @@ -0,0 +1,175 @@ +import { describe, it } from "node:test"; +import expect from "expect"; +import { + extractDescriptionExcerpt, + joinBasenames, + computeSubdivisions, + enumerateSections, +} from "../src/export/exporters/mapWithTable"; + +describe("extractDescriptionExcerpt", () => { + it("returns empty string when no description section exists", () => { + expect(extractDescriptionExcerpt(new Map())).toBe(""); + }); + + it("returns the first paragraph only", () => { + const text = new Map([ + ["description", "First sentence.\n\nSecond paragraph."], + ]); + expect(extractDescriptionExcerpt(text)).toBe("First sentence."); + }); + + it("collapses internal whitespace", () => { + const text = new Map([["description", "Line one\nLine two\nLine three"]]); + expect(extractDescriptionExcerpt(text)).toBe("Line one Line two Line three"); + }); + + it("strips wikilink syntax down to the basename", () => { + const text = new Map([ + ["description", "Visit [[towns/Saltwatch]] then [[Old Spire]]."], + ]); + expect(extractDescriptionExcerpt(text)).toBe( + "Visit Saltwatch then Old Spire.", + ); + }); + + it("uses the alias when present in a wikilink", () => { + const text = new Map([ + ["description", "The [[towns/Saltwatch|fishing town]] sits below."], + ]); + expect(extractDescriptionExcerpt(text)).toBe( + "The fishing town sits below.", + ); + }); + + it("truncates with an ellipsis when the excerpt exceeds 140 chars", () => { + const longText = "A".repeat(200); + const text = new Map([["description", longText]]); + const result = extractDescriptionExcerpt(text); + expect(result.length).toBeLessThanOrEqual(140); + expect(result.endsWith("…")).toBe(true); + }); +}); + +describe("joinBasenames", () => { + it("returns empty string for undefined / empty input", () => { + expect(joinBasenames(undefined)).toBe(""); + expect(joinBasenames([])).toBe(""); + }); + + it("joins basenames with comma + space", () => { + expect(joinBasenames(["Saltwatch", "Old Spire"])).toBe( + "Saltwatch, Old Spire", + ); + }); + + it("strips path prefixes", () => { + expect(joinBasenames(["dungeons/path/Goblin Lair"])).toBe("Goblin Lair"); + }); + + it("strips .md suffixes", () => { + expect(joinBasenames(["Saltwatch.md"])).toBe("Saltwatch"); + }); +}); + +describe("computeSubdivisions", () => { + it("returns 1×1 for a small map that fits in one page", () => { + // 10 cols × 6 rows; at 60px hex, page fits 15 cols × ~7 rows max → 1×1 + const layout = computeSubdivisions({ + gridCols: 10, + gridRows: 6, + pageWidthPx: 900, + pageHeightPx: 400, + minHexPxOnPage: 60, + }); + expect(layout.colSections).toBe(1); + expect(layout.rowSections).toBe(1); + }); + + it("subdivides when the map is too big to fit hexes at min size on one page", () => { + // 30 cols × 20 rows; at 60px hex, page fits 15 cols × ~7 rows max + // so cols need 2 sections, rows need 3 sections + const layout = computeSubdivisions({ + gridCols: 30, + gridRows: 20, + pageWidthPx: 900, + pageHeightPx: 360, + minHexPxOnPage: 60, + }); + expect(layout.colSections).toBeGreaterThanOrEqual(2); + expect(layout.rowSections).toBeGreaterThanOrEqual(3); + }); + + it("never returns a count of 0 (degenerate inputs)", () => { + const layout = computeSubdivisions({ + gridCols: 0, + gridRows: 0, + pageWidthPx: 900, + pageHeightPx: 400, + minHexPxOnPage: 60, + }); + expect(layout.colSections).toBe(1); + expect(layout.rowSections).toBe(1); + }); + + it("hexesPerSection covers the full grid when multiplied by section count", () => { + const layout = computeSubdivisions({ + gridCols: 25, + gridRows: 17, + pageWidthPx: 900, + pageHeightPx: 360, + minHexPxOnPage: 60, + }); + expect(layout.colSections * layout.hexesPerSectionCol).toBeGreaterThanOrEqual(25); + expect(layout.rowSections * layout.hexesPerSectionRow).toBeGreaterThanOrEqual(17); + }); +}); + +describe("enumerateSections", () => { + it("yields a single section for a 1×1 layout", () => { + const sections = enumerateSections( + { + colSections: 1, + rowSections: 1, + hexesPerSectionCol: 10, + hexesPerSectionRow: 8, + }, + { cols: 10, rows: 8 }, + ); + expect(sections).toHaveLength(1); + expect(sections[0].label).toBe("A1"); + expect(sections[0].colStart).toBe(0); + expect(sections[0].colEnd).toBe(10); + expect(sections[0].rowStart).toBe(0); + expect(sections[0].rowEnd).toBe(8); + }); + + it("labels columns A,B,C and rows 1,2,3 in row-major order", () => { + const sections = enumerateSections( + { + colSections: 3, + rowSections: 2, + hexesPerSectionCol: 10, + hexesPerSectionRow: 5, + }, + { cols: 30, rows: 10 }, + ); + const labels = sections.map((s) => s.label); + expect(labels).toEqual(["A1", "B1", "C1", "A2", "B2", "C2"]); + }); + + it("clips the last section's end at the grid boundary", () => { + const sections = enumerateSections( + { + colSections: 3, + rowSections: 1, + hexesPerSectionCol: 10, + hexesPerSectionRow: 8, + }, + { cols: 25, rows: 8 }, // 25 cols across 3 sections → last is 5 wide + ); + expect(sections).toHaveLength(3); + expect(sections[2].colStart).toBe(20); + expect(sections[2].colEnd).toBe(25); + }); +}); diff --git a/tests/randomTableExport.test.ts b/tests/randomTableExport.test.ts new file mode 100644 index 0000000..a43346c --- /dev/null +++ b/tests/randomTableExport.test.ts @@ -0,0 +1,249 @@ +import { describe, it } from "node:test"; +import expect from "expect"; +import { + formatRandomTableMarkdown, + formatLinkedNotesAppendix, +} from "../src/export/exporters/randomTable"; +import { stripFrontmatter } from "../src/export/mdSerializer"; +import type { RandomTable } from "../src/random-tables/randomTable"; + +const makeTable = (overrides: Partial = {}): RandomTable => ({ + dice: 0, + entries: [], + ...overrides, +}); + +describe("formatRandomTableMarkdown", () => { + it("renders the table name as an h1", () => { + const md = formatRandomTableMarkdown( + "Forest Encounters", + makeTable({ entries: [{ result: "Wolf", weight: 1 }] }), + ); + expect(md.startsWith("# Forest Encounters\n")).toBe(true); + }); + + it("omits the dice line when dice = 0", () => { + const md = formatRandomTableMarkdown( + "T", + makeTable({ entries: [{ result: "A", weight: 1 }] }), + ); + expect(md).not.toMatch(/Roll d/); + }); + + it("includes a 'Roll dN' line when dice > 0", () => { + const md = formatRandomTableMarkdown( + "T", + makeTable({ dice: 20, entries: [{ result: "A", weight: 1 }] }), + ); + expect(md).toMatch(/\*Roll d20\*/); + }); + + it("emits the description when present", () => { + const md = formatRandomTableMarkdown( + "T", + makeTable({ + entries: [{ result: "A", weight: 1 }], + description: "Some flavour text.", + }), + ); + expect(md).toMatch(/Some flavour text\./); + }); + + it("uses '#' header column when dice = 0", () => { + const md = formatRandomTableMarkdown( + "T", + makeTable({ entries: [{ result: "A", weight: 1 }] }), + ); + expect(md).toMatch(/\| # \| Result \|/); + }); + + it("uses 'Roll' header column with die ranges when dice > 0", () => { + const md = formatRandomTableMarkdown( + "T", + makeTable({ + dice: 10, + entries: [ + { result: "A", weight: 4 }, + { result: "B", weight: 6 }, + ], + }), + ); + expect(md).toMatch(/\| Roll \| Result \| Weight \|/); + // 10 faces, 4:6 split → 1–4 and 5–10 + expect(md).toMatch(/\| 1–4 \| A \| 4 \|/); + expect(md).toMatch(/\| 5–10 \| B \| 6 \|/); + }); + + it("omits the Weight column when all weights are 1", () => { + const md = formatRandomTableMarkdown( + "T", + makeTable({ + entries: [ + { result: "A", weight: 1 }, + { result: "B", weight: 1 }, + ], + }), + ); + expect(md).not.toMatch(/Weight/); + expect(md).toMatch(/\| # \| Result \|/); + }); + + it("includes the Weight column when any weight is non-1", () => { + const md = formatRandomTableMarkdown( + "T", + makeTable({ + entries: [ + { result: "A", weight: 1 }, + { result: "B", weight: 3 }, + ], + }), + ); + expect(md).toMatch(/\| # \| Result \| Weight \|/); + expect(md).toMatch(/\| 1 \| A \| 1 \|/); + expect(md).toMatch(/\| 2 \| B \| 3 \|/); + }); + + it("omits the Odds column entirely", () => { + const md = formatRandomTableMarkdown( + "T", + makeTable({ entries: [{ result: "A", weight: 1 }] }), + ); + expect(md).not.toMatch(/Odds/); + expect(md).not.toMatch(/%/); + }); + + it("escapes pipes inside entry results", () => { + const md = formatRandomTableMarkdown( + "T", + makeTable({ + entries: [{ result: "left | right", weight: 1 }], + }), + ); + expect(md).toMatch(/left \\\| right/); + }); + + it("uses row index as # column when dice = 0", () => { + const md = formatRandomTableMarkdown( + "T", + makeTable({ + entries: [ + { result: "A", weight: 1 }, + { result: "B", weight: 1 }, + { result: "C", weight: 1 }, + ], + }), + ); + expect(md).toMatch(/\| 1 \| A \|/); + expect(md).toMatch(/\| 2 \| B \|/); + expect(md).toMatch(/\| 3 \| C \|/); + }); + + it("renders empty-entries table without crashing", () => { + const md = formatRandomTableMarkdown("Empty", makeTable({ entries: [] })); + expect(md).toMatch(/# Empty/); + // Table header still present + expect(md).toMatch(/\| # \| Result \|/); + }); +}); + +describe("stripFrontmatter", () => { + it("returns content unchanged when no frontmatter is present", () => { + expect(stripFrontmatter("Hello\nworld")).toBe("Hello\nworld"); + }); + + it("strips a basic YAML block", () => { + const md = "---\ntitle: Foo\ntags: [a, b]\n---\n\n# Body"; + expect(stripFrontmatter(md)).toBe("# Body"); + }); + + it("strips with no trailing blank line", () => { + const md = "---\ntitle: Foo\n---\n# Body"; + expect(stripFrontmatter(md)).toBe("# Body"); + }); + + it("strips with CRLF line endings", () => { + const md = "---\r\ntitle: Foo\r\n---\r\n\r\n# Body"; + expect(stripFrontmatter(md)).toBe("# Body"); + }); + + it("does not strip a horizontal rule that isn't frontmatter", () => { + const md = "Some text\n\n---\n\nMore text"; + expect(stripFrontmatter(md)).toBe(md); + }); + + it("returns content unchanged when frontmatter open is unterminated", () => { + const md = "---\ntitle: Foo\nno close\n"; + expect(stripFrontmatter(md)).toBe(md); + }); + + it("handles empty frontmatter block", () => { + const md = "---\n---\n\n# Body"; + expect(stripFrontmatter(md)).toBe("# Body"); + }); +}); + +describe("formatLinkedNotesAppendix", () => { + it("returns empty string for empty input", () => { + expect(formatLinkedNotesAppendix([])).toBe(""); + }); + + it("wraps a single note in a section with the first-entry class", () => { + const out = formatLinkedNotesAppendix([ + { name: "Goblin Lair", content: "Dark cave entrance." }, + ]); + expect(out).toMatch( + /
    /, + ); + expect(out).toMatch(/## Goblin Lair/); + expect(out).toMatch(/Dark cave entrance\./); + expect(out).toMatch(/<\/section>/); + }); + + it("only the first section gets the duckmage-export-note-first class", () => { + const out = formatLinkedNotesAppendix([ + { name: "First", content: "One." }, + { name: "Second", content: "Two." }, + { name: "Third", content: "Three." }, + ]); + const firstClassMatches = out.match(/duckmage-export-note-first/g) ?? []; + expect(firstClassMatches.length).toBe(1); + }); + + it("wraps each entry in its own section block", () => { + const out = formatLinkedNotesAppendix([ + { name: "First", content: "One." }, + { name: "Second", content: "Two." }, + { name: "Third", content: "Three." }, + ]); + const openTags = out.match(/
    { + const out = formatLinkedNotesAppendix([ + { name: "Beta", content: "B" }, + { name: "Alpha", content: "A" }, + ]); + expect(out.indexOf("Beta")).toBeLessThan(out.indexOf("Alpha")); + }); + + it("trims whitespace from note content", () => { + const out = formatLinkedNotesAppendix([ + { name: "T", content: "\n\n body \n\n" }, + ]); + expect(out).toMatch(/## T\n\nbody/); + }); + + it("emits no horizontal-rule separators (visual separation is via CSS)", () => { + const out = formatLinkedNotesAppendix([ + { name: "A", content: "alpha" }, + { name: "B", content: "beta" }, + ]); + expect(out).not.toMatch(/^---$/m); + }); +}); diff --git a/tests/singleHexExport.test.ts b/tests/singleHexExport.test.ts new file mode 100644 index 0000000..fd45f32 --- /dev/null +++ b/tests/singleHexExport.test.ts @@ -0,0 +1,114 @@ +import { describe, it } from "node:test"; +import expect from "expect"; +import { + parseHexCoord, + renderSectionBlock, + HEX_SECTIONS, + type HexSectionDef, +} from "../src/export/exporters/singleHex"; + +describe("parseHexCoord", () => { + it("parses simple positive coords", () => { + expect(parseHexCoord("5_3")).toEqual({ x: 5, y: 3 }); + }); + + it("parses negative coords", () => { + expect(parseHexCoord("-2_-7")).toEqual({ x: -2, y: -7 }); + expect(parseHexCoord("-2_3")).toEqual({ x: -2, y: 3 }); + expect(parseHexCoord("5_-3")).toEqual({ x: 5, y: -3 }); + }); + + it("returns null for non-coord basenames", () => { + expect(parseHexCoord("my-cool-hex")).toBeNull(); + expect(parseHexCoord("5")).toBeNull(); + expect(parseHexCoord("5_3_extra")).toBeNull(); + expect(parseHexCoord("")).toBeNull(); + }); +}); + +describe("renderSectionBlock", () => { + const text: HexSectionDef = { + id: "description", + title: "Description", + kind: "text", + }; + const links: HexSectionDef = { + id: "towns", + title: "Towns", + kind: "links", + }; + + it("returns null for an empty text section", () => { + const data = { text: new Map(), links: new Map() }; + expect(renderSectionBlock(text, data)).toBeNull(); + }); + + it("returns null when the text section is whitespace only", () => { + const data = { + text: new Map([["description", " \n "]]), + links: new Map(), + }; + expect(renderSectionBlock(text, data)).toBeNull(); + }); + + it("wraps text content in a section block with heading", () => { + const data = { + text: new Map([["description", "A small fishing town."]]), + links: new Map(), + }; + const out = renderSectionBlock(text, data); + expect(out).toMatch(/
    /); + expect(out).toMatch(/## Description/); + expect(out).toMatch(/A small fishing town\./); + expect(out).toMatch(/<\/section>/); + }); + + it("returns null for an empty link section", () => { + const data = { text: new Map(), links: new Map() }; + expect(renderSectionBlock(links, data)).toBeNull(); + }); + + it("renders link sections as a bulleted wikilink list", () => { + const data = { + text: new Map(), + links: new Map([["towns", ["Saltwatch", "Old Spire"]]]), + }; + const out = renderSectionBlock(links, data); + expect(out).toMatch(/## Towns/); + expect(out).toMatch(/- \[\[Saltwatch\]\]/); + expect(out).toMatch(/- \[\[Old Spire\]\]/); + }); +}); + +describe("HEX_SECTIONS catalogue", () => { + it("includes all standard hex sections", () => { + const ids = HEX_SECTIONS.map((s) => s.id); + // Core text sections + expect(ids).toContain("description"); + expect(ids).toContain("landmark"); + expect(ids).toContain("weather"); + expect(ids).toContain("hooks & rumors"); + expect(ids).toContain("hidden"); + expect(ids).toContain("secret"); + // Link sections + expect(ids).toContain("towns"); + expect(ids).toContain("dungeons"); + expect(ids).toContain("features"); + expect(ids).toContain("quests"); + expect(ids).toContain("factions"); + expect(ids).toContain("encounters table"); + }); + + it("marks Hidden and Secret as GM-only", () => { + const gmOnly = HEX_SECTIONS.filter((s) => s.gmOnly).map((s) => s.id); + expect(gmOnly).toEqual(["hidden", "secret"]); + }); + + it("text sections come before link sections (semantic ordering)", () => { + const firstLinksIdx = HEX_SECTIONS.findIndex((s) => s.kind === "links"); + const lastTextIdx = HEX_SECTIONS.map((s, i) => ({ s, i })) + .filter(({ s }) => s.kind === "text") + .reduce((max, cur) => Math.max(max, cur.i), -1); + expect(lastTextIdx).toBeLessThan(firstLinksIdx); + }); +}); diff --git a/tests/singleNoteExport.test.ts b/tests/singleNoteExport.test.ts new file mode 100644 index 0000000..0f62b60 --- /dev/null +++ b/tests/singleNoteExport.test.ts @@ -0,0 +1,56 @@ +import { describe, it } from "node:test"; +import expect from "expect"; +import { ensureLeadingTitle } from "../src/export/exporters/singleNote"; + +describe("ensureLeadingTitle", () => { + it("returns content unchanged when it already starts with an H1", () => { + const raw = "# Bandit Camp\n\nA hidden hideout."; + expect(ensureLeadingTitle(raw, "Bandit Camp")).toBe(raw); + }); + + it("prepends '# {basename}' when the note has no H1", () => { + const raw = "## Layout\n\nThree rooms."; + expect(ensureLeadingTitle(raw, "Bandit Camp")).toBe( + "# Bandit Camp\n\n## Layout\n\nThree rooms.", + ); + }); + + it("strips frontmatter before checking for H1", () => { + const raw = "---\ntags: [hideout]\n---\n\n# Bandit Camp\n\nA hidden hideout."; + expect(ensureLeadingTitle(raw, "Bandit Camp")).toBe( + "# Bandit Camp\n\nA hidden hideout.", + ); + }); + + it("prepends title when frontmatter is followed by non-H1 content", () => { + const raw = "---\ntags: [hideout]\n---\n\nA hidden hideout."; + expect(ensureLeadingTitle(raw, "Bandit Camp")).toBe( + "# Bandit Camp\n\nA hidden hideout.", + ); + }); + + it("collapses leading whitespace before the first content line", () => { + const raw = "\n\n\n## Layout\n\nDetails."; + expect(ensureLeadingTitle(raw, "Bandit Camp")).toBe( + "# Bandit Camp\n\n## Layout\n\nDetails.", + ); + }); + + it("returns just the title for an empty note", () => { + expect(ensureLeadingTitle("", "Empty Note")).toBe("# Empty Note"); + }); + + it("returns just the title when the note is only frontmatter", () => { + expect(ensureLeadingTitle("---\ntags: []\n---\n", "Empty Note")).toBe( + "# Empty Note", + ); + }); + + it("doesn't prepend when content uses '#title' without a space (real H1 requires a space)", () => { + // A line like "#tag" is a tag, not a heading. We should still prepend a title. + const raw = "#mytag\n\nSome content."; + expect(ensureLeadingTitle(raw, "Note")).toBe( + "# Note\n\n#mytag\n\nSome content.", + ); + }); +}); diff --git a/tests/workflowExport.test.ts b/tests/workflowExport.test.ts new file mode 100644 index 0000000..47228b0 --- /dev/null +++ b/tests/workflowExport.test.ts @@ -0,0 +1,114 @@ +import { describe, it } from "node:test"; +import expect from "expect"; +import { + fillTemplate, + formatRollsBreakdown, +} from "../src/export/exporters/workflow"; +import type { Workflow } from "../src/random-tables/workflow"; + +const wf = (steps: Workflow["steps"]): Workflow => ({ + name: "test", + steps, +}); + +describe("fillTemplate", () => { + it("returns empty string when template is empty", () => { + expect(fillTemplate("", wf([]), [])).toBe(""); + }); + + it("returns unchanged template when no steps", () => { + expect(fillTemplate("Hello world", wf([]), [])).toBe("Hello world"); + }); + + it("substitutes $label placeholder for single-roll table step", () => { + const w = wf([ + { kind: "table", tablePath: "monsters", rolls: 1, label: "monster" }, + ]); + expect(fillTemplate("A $monster appears.", w, [["wolf"]])).toBe( + "A wolf appears.", + ); + }); + + it("substitutes $label_1, $label_2 for multi-roll step", () => { + const w = wf([ + { kind: "table", tablePath: "names", rolls: 2, label: "name" }, + ]); + expect(fillTemplate("$name_1 and $name_2", w, [["Alice", "Bob"]])).toBe( + "Alice and Bob", + ); + }); + + it("substitutes table-basename placeholder when no label", () => { + const w = wf([ + { kind: "table", tablePath: "folder/monsters", rolls: 1 }, + ]); + expect(fillTemplate("Foe: $monsters", w, [["goblin"]])).toBe( + "Foe: goblin", + ); + }); + + it("substitutes dice-formula step with the rolled value", () => { + const w = wf([ + { kind: "dice", tablePath: "", diceFormula: "2d6+3", rolls: 1, label: "hp" }, + ]); + expect(fillTemplate("HP: $hp", w, [["(4+5)+3=12"]])).toBe( + "HP: (4+5)+3=12", + ); + }); + + it("leaves unmatched placeholders as [$placeholder] when no roll provided", () => { + const w = wf([ + { kind: "table", tablePath: "x", rolls: 1, label: "name" }, + ]); + expect(fillTemplate("$name", w, [[]])).toBe("[$name]"); + }); + + it("handles spaces in labels (placeholder uses underscores)", () => { + const w = wf([ + { kind: "table", tablePath: "x", rolls: 1, label: "true name" }, + ]); + // stepVarName turns space → underscore: $true_name + expect(fillTemplate("$true_name", w, [["Vargax"]])).toBe("Vargax"); + }); +}); + +describe("formatRollsBreakdown", () => { + it("returns empty array when there are no steps", () => { + expect(formatRollsBreakdown(wf([]), [])).toEqual([]); + }); + + it("renders a header row + one row per roll", () => { + const w = wf([ + { kind: "table", tablePath: "monsters", rolls: 1, label: "monster" }, + { kind: "dice", tablePath: "", diceFormula: "2d6", rolls: 2, label: "hp" }, + ]); + const out = formatRollsBreakdown(w, [["wolf"], ["7", "11"]]); + expect(out[0]).toBe("| Step | Label | Roll | Value |"); + expect(out[1]).toMatch(/^\|\s*---/); + // Step 1, single roll → "—" + expect(out[2]).toBe("| 1 | monster | — | wolf |"); + // Step 2, two rolls + expect(out[3]).toBe("| 2 | hp | 1 | 7 |"); + expect(out[4]).toBe("| 2 | hp | 2 | 11 |"); + }); + + it("falls back to table basename when no label is set", () => { + const w = wf([ + { kind: "table", tablePath: "monsters/forest/wolves", rolls: 1 }, + ]); + const out = formatRollsBreakdown(w, [["alpha wolf"]]); + expect(out[2]).toBe("| 1 | wolves | — | alpha wolf |"); + }); + + it("falls back to dice formula when no label is set", () => { + const w = wf([{ kind: "dice", tablePath: "", diceFormula: "1d20", rolls: 1 }]); + const out = formatRollsBreakdown(w, [["17"]]); + expect(out[2]).toBe("| 1 | 1d20 | — | 17 |"); + }); + + it("escapes pipes in rolled values", () => { + const w = wf([{ kind: "table", tablePath: "x", rolls: 1, label: "thing" }]); + const out = formatRollsBreakdown(w, [["left | right"]]); + expect(out[2]).toMatch(/left \\\| right/); + }); +}); diff --git a/versions.json b/versions.json index b518dbd..7863d0d 100644 --- a/versions.json +++ b/versions.json @@ -34,5 +34,6 @@ "1.1.12": "1.12.0", "1.1.13": "1.12.0", "1.1.14": "1.12.0", - "1.1.15": "1.12.0" + "1.1.15": "1.12.0", + "1.2.0": "1.12.0" } \ No newline at end of file
    ${renderInline(c)}
    ${renderInline(c)}