feat: export module — PDF/Markdown for notes, hexes, maps, tables, workflows (1.2.0)

Adds a unified export pipeline under src/export/ with exporters for single notes,
structured hexes, maps (with reference table), random tables, and workflows
(with rolled samples). PDF rendering reuses Obsidian's MarkdownRenderer + theme
CSS; map PNG/PDF goes through a dedicated canvas renderer.

UI surface:
- "Export" tab in MapModal (PNG/PDF with overlay + size options)
- "Export" link in HexEditorModal + HexExportModal
- "Export PDF/Markdown" links in RandomTableView header
- "Export…" button in WorkflowEditorModal + WorkflowExportModal
- Commands: export current note (PDF/MD), current hex, current workflow
- File-menu items on any .md file: Export to PDF / Markdown

Other:
- New `exportFolder` setting (defaults to {worldFolder}/exports)
- hexGeometry: add hexSize / hexCenter / hexPolygonPoints / gridBoundingBox
- HexTableView + HexEditorModal: first-wins on duplicate palette names
  (matches HexMapView's .find() behaviour)
- Tests: unit + integration suites for all exporters; sim-vault fixture

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaprettycoolguy@protonmail.com 2026-06-05 20:46:41 -04:00
parent 0f910233ee
commit 6aa885b5b2
69 changed files with 7110 additions and 15 deletions

1
.gitignore vendored
View file

@ -7,3 +7,4 @@ data.json
.claude/settings.local.json
.claude/worktrees/
.claude/worktrees/*
.integration-out/

View file

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

4
package-lock.json generated
View file

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

View file

@ -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": [

View file

@ -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

View file

@ -331,6 +331,7 @@ export const DEFAULT_SETTINGS: HexmakerPluginSettings = {
defaultSubmapCols: 10,
defaultSubmapRows: 10,
workflowsFolder: "",
exportFolder: "",
hiddenIcons: [],
iconOrder: [],
setupComplete: false,

View file

@ -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<string> {
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<void> {
if (app.vault.getAbstractFileByPath(path)) return;
try {
await app.vault.createFolder(path);
} catch {
/* race: already exists */
}
}

View file

@ -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<void> {
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: `<div style="width: 100%; font-size: 9px; text-align: center; color: #666;"><span class="pageNumber"></span> / <span class="totalPages"></span></div>`,
});
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<string> {
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(
`<img src="${fullMapImageUri}" alt="${escapeHtmlAttr(mapName)} full map" class="duckmage-pdf-fullmap">`,
);
lines.push("");
// ── Page 2: Section TOC ───────────────────────────────────────────────
lines.push(`<div class="duckmage-pdf-pagebreak"></div>`);
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(`<div class="duckmage-pdf-pagebreak"></div>`);
lines.push("");
lines.push(`## Section ${s.label}`);
lines.push("");
lines.push(
`<img src="${imgUri}" alt="${escapeHtmlAttr(mapName)} section ${s.label}" class="duckmage-pdf-sectionmap">`,
);
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<HexRow[]> {
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, string>): 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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
}
function sanitiseFilename(name: string): string {
return name.replace(/[\\/:*?"<>|]/g, "_");
}
async function blobToDataUri(blob: Blob, type: string): Promise<string> {
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<void> {
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<void> {
const file = app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) {
await app.workspace.getLeaf(false).openFile(file);
}
}

View file

@ -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<void> {
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: `<div style="width: 100%; font-size: 9px; text-align: center; color: #666;"><span class="pageNumber"></span> / <span class="totalPages"></span></div>`,
});
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<void> {
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<string> {
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<LinkedNote[]> {
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 `<section class="duckmage-export-note">` 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 (
`<section class="${cls}">\n\n` +
`## ${n.name}\n\n` +
`${n.content.trim()}\n\n` +
`</section>`
);
});
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<void> {
// 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<void> {
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<void> {
const file = app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) {
await app.workspace.getLeaf(false).openFile(file);
}
}

View file

@ -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 `<section class="duckmage-hex-section">` 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<string>;
}
export async function exportHexAsPdf(
plugin: HexmakerPlugin,
hexFile: TFile,
opts: SingleHexExportOptions = {},
): Promise<void> {
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: `<div style="width: 100%; font-size: 9px; text-align: center; color: #666;"><span class="pageNumber"></span> / <span class="totalPages"></span></div>`,
});
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<void> {
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<string> {
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<string, string>; links: Map<string, string[]> },
): 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 `<section class="duckmage-hex-section">` 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 (
`<section class="duckmage-hex-section">\n\n` +
`## ${title}\n\n` +
`${body}\n\n` +
`</section>\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<void> {
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<void> {
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<void> {
const file = app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) {
await app.workspace.getLeaf(false).openFile(file);
}
}

View file

@ -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<void> {
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: `<div style="width: 100%; font-size: 9px; text-align: center; color: #666;"><span class="pageNumber"></span> / <span class="totalPages"></span></div>`,
});
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<void> {
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<string> {
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<void> {
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<void> {
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<void> {
const file = app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) {
await app.workspace.getLeaf(false).openFile(file);
}
}

View file

@ -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 <section> 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<void> {
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: `<div style="width: 100%; font-size: 9px; text-align: center; color: #666;"><span class="pageNumber"></span> / <span class="totalPages"></span></div>`,
});
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<void> {
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<string> {
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(`<section class="duckmage-workflow-sample">`);
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(`</section>`);
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<WorkflowSample> {
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<string> {
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<string> {
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<void> {
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<void> {
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<void> {
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;

View file

@ -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 <title> 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");
}

View file

@ -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, 01. 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 };

View file

@ -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+/, "");
}

158
src/export/pdfExporter.ts Normal file
View file

@ -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));

217
src/export/printScaffold.ts Normal file
View file

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* 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; }
}
`;

View file

@ -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 };

View file

@ -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();
});
}
}

View file

@ -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 ──────────────────────────────────────────────────────────────

View file

@ -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(

View file

@ -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;

View file

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

View file

@ -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());

View file

@ -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));
}

View file

@ -94,6 +94,7 @@ export interface HexmakerPluginSettings {
defaultSubmapCols: number;
defaultSubmapRows: number;
workflowsFolder: string;
exportFolder: string;
hiddenIcons: string[];
iconOrder: string[];
setupComplete: boolean;

View file

@ -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;
}

60
tests/fixtures/sample-hex.md vendored Normal file
View file

@ -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]]

15
tests/fixtures/sample-random-table.md vendored Normal file
View file

@ -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 |

21
tests/fixtures/sample-town.md vendored Normal file
View file

@ -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.

55
tests/fixtures/sim-vault/README.md vendored Normal file
View file

@ -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.

View file

@ -0,0 +1,3 @@
### Overview
A crumbling watchtower of black stone, half-submerged at high tide.

View file

@ -0,0 +1,5 @@
---
faction-color: "#cd5c5c"
---
A coastal merchant alliance claiming most of the southern trade routes.

View file

@ -0,0 +1 @@
A weather-worn standing stone, taller than two riders.

View file

@ -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]]

View file

@ -0,0 +1,8 @@
---
terrain: ocean
region: Coastlands
---
### Description
Open ocean stretching to the southern horizon.

View file

@ -0,0 +1,16 @@
---
terrain: forest
region: Coastlands
---
### Description
Dense pine forest, the air sharp with sap.
### Features
- [[Standing Stone]]
### Quests
- [[Find the Wanderer]]

View file

@ -0,0 +1,15 @@
---
terrain: grass
---
### Description
Rolling pastureland.
### Towns
- [[Saltwatch]]
### Factions
- [[The Reach]]

View file

@ -0,0 +1,3 @@
---
terrain: mountain
---

View file

@ -0,0 +1,3 @@
---
terrain: hills
---

View file

@ -0,0 +1 @@
Recover a missing scout last seen heading into the [[Standing Stone]] hex.

View file

@ -0,0 +1,5 @@
---
region-color: "#6e8eb1"
---
The narrow coastal strip from Saltwatch south to the cliffs.

View file

@ -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 |

View file

@ -0,0 +1,11 @@
---
dice: 20
---
| Result | Weight |
|--------|--------|
| Bromund | 1 |
| Lirel | 1 |
| Vargax | 1 |
| Saskia | 1 |
| Olwen | 1 |

View file

@ -0,0 +1,11 @@
---
linkedFolder: tables/treasure
---
Auto-synced from the treasure folder.
| Result | Weight |
|--------|--------|
| [[gold pile]] | 1 |
| [[silver ring]] | 1 |
| [[cursed amulet]] | 1 |

View file

@ -0,0 +1 @@
Black iron amulet that whispers at midnight. **Wearer becomes restless.**

View file

@ -0,0 +1 @@
A heap of unminted gold ingots wrapped in a moldering cloak.

View file

@ -0,0 +1 @@
A silver ring etched with a *running stag*. Slightly tarnished.

View file

@ -0,0 +1,12 @@
---
population: 320
---
### Overview
A small fishing town clinging to the cliffs.
### Notable Residents
- **Maren Kell** — harbourmaster.
- **Old Tomeas** — smith.

View file

@ -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 |

View file

@ -0,0 +1,3 @@
# $name
A traveller with **HP $hp** wanders the road.

423
tests/helpers/simVault.ts Normal file
View file

@ -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);

138
tests/hexGeometry.test.ts Normal file
View file

@ -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);
});
});

View file

@ -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 \|/);
});
});

View file

@ -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;

View file

@ -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/);
});
});

View file

@ -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);
});
});

View file

@ -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/);
});
});

View file

@ -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)}</title>
<style>${PRINT_PATCH_CSS}</style>
</head><body class="theme-light">${body}</body></html>`;
}
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("</tbody></table>");
inTable = false;
headerEmitted = false;
}
};
const closeList = () => {
if (inList) {
out.push("</ul>");
inList = false;
}
};
const closeAll = () => {
closeTable();
closeList();
};
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, "");
if (line.startsWith("### ")) {
closeAll();
out.push(`<h3>${escapeHtml(line.slice(4))}</h3>`);
continue;
}
if (line.startsWith("## ")) {
closeAll();
out.push(`<h2>${escapeHtml(line.slice(3))}</h2>`);
continue;
}
if (line.startsWith("# ")) {
closeAll();
out.push(`<h1>${escapeHtml(line.slice(2))}</h1>`);
continue;
}
if (/^-{3,}\s*$/.test(line)) {
closeAll();
out.push("<hr>");
continue;
}
if (line.startsWith("<")) {
closeAll();
out.push(line);
continue;
}
if (/^[-*] /.test(line)) {
closeTable();
if (!inList) {
out.push("<ul>");
inList = true;
}
out.push(`<li>${renderInline(line.slice(2))}</li>`);
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("<table><thead>");
inTable = true;
}
if (!headerEmitted) {
out.push("<tr>" + cells.map((c) => `<th>${renderInline(c)}</th>`).join("") + "</tr>");
out.push("</thead><tbody>");
headerEmitted = true;
} else {
out.push("<tr>" + cells.map((c) => `<td>${renderInline(c)}</td>`).join("") + "</tr>");
}
continue;
}
closeAll();
out.push(`<p>${renderInline(line)}</p>`);
}
closeAll();
return out.join("\n");
}
function renderInline(s: string): string {
return s
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) =>
`<img src="${url.replace(/"/g, "&quot;")}" alt="${escapeHtml(alt)}">`,
)
.split(/(<img[^>]*>)/)
.map((chunk) => {
if (chunk.startsWith("<img")) return chunk;
return escapeHtml(chunk)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/\\\|/g, "|");
})
.join("");
}

View file

@ -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
* mdhtml 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 <webview>.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 **610 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 =
`<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900">` +
`<rect width="1200" height="900" fill="#9bbcd9"/>` +
`<rect x="50" y="50" width="1100" height="800" fill="none" stroke="#1d4d80" stroke-width="6"/>` +
`<text x="600" y="450" text-anchor="middle" font-family="sans-serif" font-size="48" fill="#1d4d80">` +
`Oversized test image (1200x900)</text></svg>`;
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 **610 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<string> {
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 markdownHTML 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 `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title>
<style>${PRINT_PATCH_CSS}</style>
</head><body class="theme-light">${body}</body></html>`;
}
/**
* Minimal markdownHTML 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("</tbody></table>");
inTable = false;
headerEmitted = false;
}
};
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, "");
if (line.startsWith("### ")) {
closeTable();
out.push(`<h3>${escapeHtml(line.slice(4))}</h3>`);
continue;
}
if (line.startsWith("## ")) {
closeTable();
out.push(`<h2>${escapeHtml(line.slice(3))}</h2>`);
continue;
}
if (line.startsWith("# ")) {
closeTable();
out.push(`<h1>${escapeHtml(line.slice(2))}</h1>`);
continue;
}
if (/^-{3,}\s*$/.test(line)) {
closeTable();
out.push("<hr>");
continue;
}
// Inline HTML block (e.g. <section>, </section>) — 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("<table><thead>");
inTable = true;
}
if (!headerEmitted) {
out.push("<tr>" + cells.map((c) => `<th>${renderInline(c)}</th>`).join("") + "</tr>");
out.push("</thead><tbody>");
headerEmitted = true;
} else {
out.push("<tr>" + cells.map((c) => `<td>${renderInline(c)}</td>`).join("") + "</tr>");
}
continue;
}
closeTable();
out.push(`<p>${renderInline(line)}</p>`);
}
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) =>
`<img src="${url.replace(/"/g, "&quot;")}" alt="${escapeHtml(alt)}">`,
)
.split(/(<img[^>]*>)/)
.map((chunk) => {
if (chunk.startsWith("<img")) return chunk;
return escapeHtml(chunk)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/\\\|/g, "|");
})
.join("");
}

View file

@ -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`,
``,
`<section class="duckmage-hex-section">`,
``,
`## Description`,
``,
`The trees lean north against an old wind. A scoured standing stone juts from the moss.`,
``,
`</section>`,
``,
`<section class="duckmage-hex-section">`,
``,
`## Landmark`,
``,
`Pale stone, lichen-mottled, taller than two riders.`,
``,
`</section>`,
``,
`<section class="duckmage-hex-section">`,
``,
`## Weather`,
``,
`Misty mornings; warm afternoons.`,
``,
`</section>`,
``,
`<section class="duckmage-hex-section">`,
``,
`## Hooks & Rumors`,
``,
`- The standing stone hums at midwinter.`,
`- A masked rider was seen two evenings ago.`,
``,
`</section>`,
``,
`<section class="duckmage-hex-section">`,
``,
`## Hidden`,
``,
`A weathered cache beneath the standing stone holds three gold ingots.`,
``,
`</section>`,
``,
`<section class="duckmage-hex-section">`,
``,
`## Secret`,
``,
`The standing stone is one of seven binding a sleeping power.`,
``,
`</section>`,
``,
`<section class="duckmage-hex-section">`,
``,
`## Towns`,
``,
`- [[Saltwatch]]`,
``,
`</section>`,
``,
`<section class="duckmage-hex-section">`,
``,
`## Dungeons`,
``,
`- [[The Old Spire]]`,
`- [[Sunken Crypt]]`,
``,
`</section>`,
``,
`<section class="duckmage-hex-section">`,
``,
`## Features`,
``,
`- [[Standing Stone]]`,
`- [[Forgotten Path]]`,
``,
`</section>`,
``,
`<section class="duckmage-hex-section">`,
``,
`## Quests`,
``,
`- [[Find the Wanderer]]`,
``,
`</section>`,
``,
`<section class="duckmage-hex-section">`,
``,
`## Factions`,
``,
`- [[The Reach]]`,
``,
`</section>`,
``,
`<section class="duckmage-hex-section">`,
``,
`## Encounters Table`,
``,
`- [[Forest - Encounters]]`,
``,
`</section>`,
].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 `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title>
<style>${PRINT_PATCH_CSS}</style>
</head><body class="theme-light">${body}</body></html>`;
}
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("</tbody></table>");
inTable = false;
headerEmitted = false;
}
};
const closeList = () => {
if (inList) {
out.push("</ul>");
inList = false;
}
};
const closeAll = () => {
closeTable();
closeList();
};
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, "");
if (line.startsWith("### ")) {
closeAll();
out.push(`<h3>${escapeHtml(line.slice(4))}</h3>`);
continue;
}
if (line.startsWith("## ")) {
closeAll();
out.push(`<h2>${escapeHtml(line.slice(3))}</h2>`);
continue;
}
if (line.startsWith("# ")) {
closeAll();
out.push(`<h1>${escapeHtml(line.slice(2))}</h1>`);
continue;
}
if (/^-{3,}\s*$/.test(line)) {
closeAll();
out.push("<hr>");
continue;
}
if (line.startsWith("<")) {
closeAll();
out.push(line);
continue;
}
if (/^[-*] /.test(line)) {
closeTable();
if (!inList) {
out.push("<ul>");
inList = true;
}
out.push(`<li>${renderInline(line.slice(2))}</li>`);
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("<table><thead>");
inTable = true;
}
if (!headerEmitted) {
out.push("<tr>" + cells.map((c) => `<th>${renderInline(c)}</th>`).join("") + "</tr>");
out.push("</thead><tbody>");
headerEmitted = true;
} else {
out.push("<tr>" + cells.map((c) => `<td>${renderInline(c)}</td>`).join("") + "</tr>");
}
continue;
}
closeAll();
out.push(`<p>${renderInline(line)}</p>`);
}
closeAll();
return out.join("\n");
}
function renderInline(s: string): string {
return s
.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_m, target: string, alias: string | undefined) =>
alias ? alias : target,
)
.split(/(<img[^>]*>)/)
.map((chunk) => {
if (chunk.startsWith("<img")) return chunk;
return escapeHtml(chunk)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/\\\|/g, "|");
})
.join("");
}

View file

@ -0,0 +1,209 @@
/**
* Integration test for the single-note PDF export pipeline (Phase 1.2).
*
* Pipeline:
* read fixture stripFrontmatter ensureLeadingTitle mini mdhtml
* 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 `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title>
<style>${PRINT_PATCH_CSS}</style>
</head><body class="theme-light">${body}</body></html>`;
}
/**
* Minimal markdownHTML for our exporter's emitted subset:
* h1/h2/h3, ul (bullets), tables, paragraphs, ![alt](url), `<section>` 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("</tbody></table>");
inTable = false;
headerEmitted = false;
}
};
const closeList = () => {
if (inList) {
out.push("</ul>");
inList = false;
}
};
const closeAll = () => {
closeTable();
closeList();
};
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, "");
if (line.startsWith("### ")) {
closeAll();
out.push(`<h3>${escapeHtml(line.slice(4))}</h3>`);
continue;
}
if (line.startsWith("## ")) {
closeAll();
out.push(`<h2>${escapeHtml(line.slice(3))}</h2>`);
continue;
}
if (line.startsWith("# ")) {
closeAll();
out.push(`<h1>${escapeHtml(line.slice(2))}</h1>`);
continue;
}
if (/^-{3,}\s*$/.test(line)) {
closeAll();
out.push("<hr>");
continue;
}
if (line.startsWith("<")) {
closeAll();
out.push(line);
continue;
}
if (/^[-*] /.test(line)) {
closeTable();
if (!inList) {
out.push("<ul>");
inList = true;
}
out.push(`<li>${renderInline(line.slice(2))}</li>`);
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("<table><thead>");
inTable = true;
}
if (!headerEmitted) {
out.push("<tr>" + cells.map((c) => `<th>${renderInline(c)}</th>`).join("") + "</tr>");
out.push("</thead><tbody>");
headerEmitted = true;
} else {
out.push("<tr>" + cells.map((c) => `<td>${renderInline(c)}</td>`).join("") + "</tr>");
}
continue;
}
closeAll();
out.push(`<p>${renderInline(line)}</p>`);
}
closeAll();
return out.join("\n");
}
function renderInline(s: string): string {
return s
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) =>
`<img src="${url.replace(/"/g, "&quot;")}" alt="${escapeHtml(alt)}">`,
)
.split(/(<img[^>]*>)/)
.map((chunk) => {
if (chunk.startsWith("<img")) return chunk;
return escapeHtml(chunk)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/\\\|/g, "|");
})
.join("");
}

View file

@ -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`,
``,
`<section class="duckmage-workflow-sample">`,
``,
`### 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).`,
``,
`</section>`,
``,
`<section class="duckmage-workflow-sample">`,
``,
`### 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).`,
``,
`</section>`,
``,
`<section class="duckmage-workflow-sample">`,
``,
`### 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).`,
``,
`</section>`,
].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 `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title>
<style>${PRINT_PATCH_CSS}</style>
</head><body class="theme-light">${body}</body></html>`;
}
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("</tbody></table>");
inTable = false;
headerEmitted = false;
}
};
const closeList = () => {
if (inList) {
out.push("</ul>");
inList = false;
}
};
const closeAll = () => {
closeTable();
closeList();
};
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, "");
if (line.startsWith("```")) {
closeAll();
if (inFence) {
out.push(`<pre><code>${escapeHtml(fenceBuf.join("\n"))}</code></pre>`);
fenceBuf.length = 0;
inFence = false;
} else {
inFence = true;
}
continue;
}
if (inFence) {
fenceBuf.push(line);
continue;
}
if (line.startsWith("### ")) {
closeAll();
out.push(`<h3>${escapeHtml(line.slice(4))}</h3>`);
continue;
}
if (line.startsWith("## ")) {
closeAll();
out.push(`<h2>${escapeHtml(line.slice(3))}</h2>`);
continue;
}
if (line.startsWith("# ")) {
closeAll();
out.push(`<h1>${escapeHtml(line.slice(2))}</h1>`);
continue;
}
if (/^-{3,}\s*$/.test(line)) {
closeAll();
out.push("<hr>");
continue;
}
if (line.startsWith("<")) {
closeAll();
out.push(line);
continue;
}
if (/^[-*] /.test(line)) {
closeTable();
if (!inList) {
out.push("<ul>");
inList = true;
}
out.push(`<li>${renderInline(line.slice(2))}</li>`);
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("<table><thead>");
inTable = true;
}
if (!headerEmitted) {
out.push("<tr>" + cells.map((c) => `<th>${renderInline(c)}</th>`).join("") + "</tr>");
out.push("</thead><tbody>");
headerEmitted = true;
} else {
out.push("<tr>" + cells.map((c) => `<td>${renderInline(c)}</td>`).join("") + "</tr>");
}
continue;
}
closeAll();
out.push(`<p>${renderInline(line)}</p>`);
}
closeAll();
return out.join("\n");
}
function renderInline(s: string): string {
return escapeHtml(s)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/\\\|/g, "|");
}

View file

@ -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);
});
});

View file

@ -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> = {}): 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 → 14 and 510
expect(md).toMatch(/\| 14 \| A \| 4 \|/);
expect(md).toMatch(/\| 510 \| 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(
/<section class="duckmage-export-note duckmage-export-note-first">/,
);
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(/<section class="duckmage-export-note/g) ?? [];
const closeTags = out.match(/<\/section>/g) ?? [];
expect(openTags.length).toBe(3);
expect(closeTags.length).toBe(3);
expect(out).toMatch(/## First/);
expect(out).toMatch(/## Second/);
expect(out).toMatch(/## Third/);
});
it("preserves entry order", () => {
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);
});
});

View file

@ -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(/<section class="duckmage-hex-section">/);
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);
});
});

View file

@ -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.",
);
});
});

View file

@ -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/);
});
});

View file

@ -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"
}