mirror of
https://github.com/sbuffkin/hexmaker.git
synced 2026-07-22 06:14:01 +00:00
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:
parent
0f910233ee
commit
6aa885b5b2
69 changed files with 7110 additions and 15 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -7,3 +7,4 @@ data.json
|
|||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
.claude/worktrees/*
|
||||
.integration-out/
|
||||
|
|
|
|||
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -331,6 +331,7 @@ export const DEFAULT_SETTINGS: HexmakerPluginSettings = {
|
|||
defaultSubmapCols: 10,
|
||||
defaultSubmapRows: 10,
|
||||
workflowsFolder: "",
|
||||
exportFolder: "",
|
||||
hiddenIcons: [],
|
||||
iconOrder: [],
|
||||
setupComplete: false,
|
||||
|
|
|
|||
38
src/export/exportFolder.ts
Normal file
38
src/export/exportFolder.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
456
src/export/exporters/mapWithTable.ts
Normal file
456
src/export/exporters/mapWithTable.ts
Normal 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, "&").replace(/"/g, """).replace(/</g, "<");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
250
src/export/exporters/randomTable.ts
Normal file
250
src/export/exporters/randomTable.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
263
src/export/exporters/singleHex.ts
Normal file
263
src/export/exporters/singleHex.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
132
src/export/exporters/singleNote.ts
Normal file
132
src/export/exporters/singleNote.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
354
src/export/exporters/workflow.ts
Normal file
354
src/export/exporters/workflow.ts
Normal 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;
|
||||
75
src/export/htmlRenderer.ts
Normal file
75
src/export/htmlRenderer.ts
Normal 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");
|
||||
}
|
||||
|
||||
751
src/export/mapPngRenderer.ts
Normal file
751
src/export/mapPngRenderer.ts
Normal 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, 0–1. Default 0.4. Multiple factions on the
|
||||
* same hex are layered, so the effective coverage compounds.
|
||||
*/
|
||||
overlayOpacity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the named map to a PNG blob. Returns the blob; caller writes it.
|
||||
* Throws if the map name isn't found in settings.
|
||||
*/
|
||||
export async function renderMapToPngBlob(
|
||||
plugin: HexmakerPlugin,
|
||||
mapName: string,
|
||||
opts: MapPngRenderOptions = {},
|
||||
): Promise<Blob> {
|
||||
const map = plugin.settings.maps.find((m) => m.name === mapName);
|
||||
if (!map) throw new Error(`Map "${mapName}" not found`);
|
||||
|
||||
const R = opts.hexRadius ?? 50;
|
||||
const padding = opts.padding ?? 40;
|
||||
const showCoords = opts.showCoords ?? true;
|
||||
const showIcons = opts.showIcons ?? true;
|
||||
const showPaths = opts.showPaths ?? true;
|
||||
const showFactionOverlay = opts.showFactionOverlay ?? false;
|
||||
const showRegionOverlay = opts.showRegionOverlay ?? false;
|
||||
const overlayOpacity = clamp(opts.overlayOpacity ?? 0.4, 0, 1);
|
||||
const background = opts.background ?? "#1a1a1a";
|
||||
const borderColor = opts.borderColor ?? "#222";
|
||||
const coordColor = opts.coordColor ?? "#bbb";
|
||||
|
||||
const orientation = plugin.settings.hexOrientation;
|
||||
const isFlat = orientation === "flat";
|
||||
const stagger = map.staggerOffset ?? plugin.settings.staggerOffset;
|
||||
const palette = plugin.settings.terrainPalettes.find(
|
||||
(p) => p.name === map.paletteName,
|
||||
);
|
||||
// First-occurrence wins, matching HexMapView's `.find()` behaviour. Users
|
||||
// can end up with duplicate-named entries in their palette (e.g. after
|
||||
// copy-paste edits in settings); the first one is canonical.
|
||||
const terrainByName = new Map<string, TerrainColor>();
|
||||
for (const t of palette?.terrains ?? []) {
|
||||
if (!terrainByName.has(t.name)) terrainByName.set(t.name, t);
|
||||
}
|
||||
|
||||
const { cols, rows } = map.gridSize;
|
||||
const { x: ox, y: oy } = map.gridOffset;
|
||||
|
||||
// Clamp the subgrid range to the actual grid bounds. When unset, the range
|
||||
// covers everything → equivalent to the previous behaviour.
|
||||
const colStart = Math.max(0, opts.subgrid?.colStart ?? 0);
|
||||
const colEnd = Math.min(cols, opts.subgrid?.colEnd ?? cols);
|
||||
const rowStart = Math.max(0, opts.subgrid?.rowStart ?? 0);
|
||||
const rowEnd = Math.min(rows, opts.subgrid?.rowEnd ?? rows);
|
||||
|
||||
// Step 1: pre-compute centres for every cell IN RANGE. centerMap is keyed
|
||||
// by the "x_y" hex coord (the same scheme path chains use).
|
||||
const centerMap = new Map<string, { cx: number; cy: number }>();
|
||||
for (let row = rowStart; row < rowEnd; row++) {
|
||||
for (let col = colStart; col < colEnd; col++) {
|
||||
const c = hexCenter(col, row, orientation, R, stagger);
|
||||
centerMap.set(`${col + ox}_${row + oy}`, c);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: pixel-shift all centres so the grid sits at (padding, padding).
|
||||
const allCenters = Array.from(centerMap.values());
|
||||
const minX = Math.min(...allCenters.map((c) => c.cx)) - R;
|
||||
const minY = Math.min(...allCenters.map((c) => c.cy)) - R;
|
||||
const maxX = Math.max(...allCenters.map((c) => c.cx)) + R;
|
||||
const maxY = Math.max(...allCenters.map((c) => c.cy)) + R;
|
||||
const shifted = new Map<string, { cx: number; cy: number }>();
|
||||
for (const [k, c] of centerMap) {
|
||||
shifted.set(k, { cx: c.cx - minX + padding, cy: c.cy - minY + padding });
|
||||
}
|
||||
const W = Math.ceil(maxX - minX + padding * 2);
|
||||
const H = Math.ceil(maxY - minY + padding * 2);
|
||||
|
||||
// Step 3a: build colour maps for overlays (faction-basename → hex colour,
|
||||
// region-name → hex colour). Empty if the respective overlay is off.
|
||||
const factionColorMap = showFactionOverlay
|
||||
? buildFactionColorMap(plugin)
|
||||
: new Map<string, string>();
|
||||
const regionColorMap = showRegionOverlay
|
||||
? buildRegionColorMap(plugin)
|
||||
: new Map<string, string>();
|
||||
|
||||
// Step 3b: collect every hex's terrain + icon override + overlay info before
|
||||
// drawing so we can pre-load icon images once each. We also track which
|
||||
// factions / regions are actually present on this map so the legend at the
|
||||
// end can show just those (not the entire vault folder).
|
||||
interface HexState {
|
||||
key: string;
|
||||
cx: number;
|
||||
cy: number;
|
||||
terrain?: TerrainColor;
|
||||
iconName?: string;
|
||||
factionColors: string[];
|
||||
regionColor?: string;
|
||||
}
|
||||
const hexes: HexState[] = [];
|
||||
const iconsNeeded = new Set<string>();
|
||||
const factionsUsed = new Map<string, string>(); // name → color
|
||||
const regionsUsed = new Map<string, string>(); // name → color
|
||||
// Per-region hex centres, used to position the on-map region label at the
|
||||
// cluster centroid (with PCA-derived rotation). Same approach as the
|
||||
// on-screen `renderRegionOverlay` in HexMapView.
|
||||
const regionHexCenters = new Map<string, { cx: number; cy: number }[]>();
|
||||
for (let row = rowStart; row < rowEnd; row++) {
|
||||
for (let col = colStart; col < colEnd; col++) {
|
||||
const hx = col + ox;
|
||||
const hy = row + oy;
|
||||
const key = `${hx}_${hy}`;
|
||||
const c = shifted.get(key);
|
||||
if (!c) continue;
|
||||
const notePath = plugin.hexPath(hx, hy, mapName);
|
||||
const file = plugin.app.vault.getAbstractFileByPath(notePath);
|
||||
|
||||
let terrain: TerrainColor | undefined;
|
||||
let iconOverride: string | undefined;
|
||||
const factionColors: string[] = [];
|
||||
let regionColor: string | undefined;
|
||||
if (file instanceof TFile) {
|
||||
const terrainName = getTerrainFromFile(plugin.app, file.path);
|
||||
if (terrainName) terrain = terrainByName.get(terrainName);
|
||||
iconOverride = getIconOverrideFromFile(plugin.app, file.path) ?? undefined;
|
||||
if (showFactionOverlay) {
|
||||
for (const fName of getHexFactionLinks(plugin.app, file)) {
|
||||
const c = factionColorMap.get(fName);
|
||||
if (c) {
|
||||
factionColors.push(c);
|
||||
factionsUsed.set(fName, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showRegionOverlay) {
|
||||
const regionName = getHexRegionName(plugin.app, file);
|
||||
if (regionName) {
|
||||
const c = regionColorMap.get(regionName);
|
||||
if (c) {
|
||||
regionColor = c;
|
||||
regionsUsed.set(regionName, c);
|
||||
const center = shifted.get(`${hx}_${hy}`);
|
||||
if (center) {
|
||||
if (!regionHexCenters.has(regionName)) {
|
||||
regionHexCenters.set(regionName, []);
|
||||
}
|
||||
regionHexCenters.get(regionName)!.push(center);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const iconName = iconOverride ?? terrain?.icon;
|
||||
if (iconName && showIcons) iconsNeeded.add(iconName);
|
||||
hexes.push({
|
||||
key,
|
||||
cx: c.cx,
|
||||
cy: c.cy,
|
||||
terrain,
|
||||
iconName,
|
||||
factionColors,
|
||||
regionColor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const iconCache = await loadIcons(plugin, iconsNeeded);
|
||||
|
||||
// Step 4: render to canvas. OffscreenCanvas is reliable in Obsidian's
|
||||
// Electron Chromium; convertToBlob() is Promise-based.
|
||||
const canvas = new OffscreenCanvas(W, H);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Could not get 2D canvas context");
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Hexes — base fill + overlay tints + icons + coords
|
||||
for (const hex of hexes) {
|
||||
drawHex(
|
||||
ctx,
|
||||
hex.cx,
|
||||
hex.cy,
|
||||
R,
|
||||
orientation,
|
||||
hex.terrain?.color ?? "#2a2a2a",
|
||||
borderColor,
|
||||
);
|
||||
// Region overlay first (under faction): regions are larger, factions
|
||||
// are usually narrower local groups, so faction tint reads on top.
|
||||
if (showRegionOverlay && hex.regionColor) {
|
||||
fillHexPolygon(ctx, hex.cx, hex.cy, R, orientation, hex.regionColor, overlayOpacity);
|
||||
}
|
||||
if (showFactionOverlay) {
|
||||
for (const color of hex.factionColors) {
|
||||
fillHexPolygon(ctx, hex.cx, hex.cy, R, orientation, color, overlayOpacity);
|
||||
}
|
||||
}
|
||||
if (showIcons && hex.iconName) {
|
||||
const img = iconCache.get(hex.iconName);
|
||||
if (img) {
|
||||
const size = R * 0.9;
|
||||
ctx.drawImage(img, hex.cx - size / 2, hex.cy - size / 2, size, size);
|
||||
}
|
||||
}
|
||||
if (showCoords) {
|
||||
const [hxStr, hyStr] = hex.key.split("_");
|
||||
ctx.fillStyle = coordColor;
|
||||
ctx.font = `${Math.round(R * 0.22)}px sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "alphabetic";
|
||||
ctx.fillText(`${hxStr},${hyStr}`, hex.cx, hex.cy + R * 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
// Region labels — drawn over the map at each region's centroid, rotated
|
||||
// along the cluster's principal axis. Same algorithm as the on-screen
|
||||
// `renderRegionOverlay`.
|
||||
if (showRegionOverlay && regionHexCenters.size > 0) {
|
||||
for (const [regionName, centers] of regionHexCenters) {
|
||||
drawRegionLabel(ctx, regionName, centers, R);
|
||||
}
|
||||
}
|
||||
|
||||
// Faction key — a labelled swatch panel at the bottom-left. Only lists
|
||||
// factions actually used on this map.
|
||||
if (showFactionOverlay && factionsUsed.size > 0) {
|
||||
const fontSize = Math.max(11, Math.round(R * 0.28));
|
||||
const legendPadding = 16;
|
||||
const size = drawLegend(
|
||||
ctx,
|
||||
padding,
|
||||
0,
|
||||
"Factions",
|
||||
sortedEntries(factionsUsed),
|
||||
fontSize,
|
||||
legendPadding,
|
||||
true, // measure pass first to anchor from the bottom
|
||||
);
|
||||
drawLegend(
|
||||
ctx,
|
||||
padding,
|
||||
H - padding - size.height,
|
||||
"Factions",
|
||||
sortedEntries(factionsUsed),
|
||||
fontSize,
|
||||
legendPadding,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// Paths
|
||||
if (showPaths) {
|
||||
for (const pt of plugin.settings.pathTypes) {
|
||||
const dash = dashFor(pt.lineStyle);
|
||||
const chains = map.pathChains.filter((c) => c.typeName === pt.name);
|
||||
for (const chain of chains) {
|
||||
let pts;
|
||||
let smooth: boolean;
|
||||
if (pt.routing === "edge") {
|
||||
pts = buildEdgePts(chain.hexes, shifted, isFlat, R);
|
||||
smooth = false;
|
||||
} else if (pt.routing === "meander") {
|
||||
pts = buildMeanderPts(chain.hexes, shifted);
|
||||
smooth = true;
|
||||
} else {
|
||||
pts = chain.hexes
|
||||
.map((k) => shifted.get(k))
|
||||
.filter((p): p is { cx: number; cy: number } => !!p);
|
||||
smooth = true;
|
||||
}
|
||||
if (pts.length < 2) continue;
|
||||
drawPath(ctx, pts, pt.color, pt.width, dash, smooth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return canvas.convertToBlob({ type: "image/png" });
|
||||
}
|
||||
|
||||
// ── Drawing primitives ──────────────────────────────────────────────────────
|
||||
|
||||
function drawHex(
|
||||
ctx: OffscreenCanvasRenderingContext2D,
|
||||
cx: number,
|
||||
cy: number,
|
||||
radius: number,
|
||||
orientation: "flat" | "pointy",
|
||||
fillColor: string,
|
||||
borderColor: string,
|
||||
): void {
|
||||
const pts = hexPolygonPoints(cx, cy, orientation, radius);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0].x, pts[0].y);
|
||||
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fill();
|
||||
ctx.lineWidth = Math.max(1, radius * 0.04);
|
||||
ctx.strokeStyle = borderColor;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawPath(
|
||||
ctx: OffscreenCanvasRenderingContext2D,
|
||||
pts: { cx: number; cy: number }[],
|
||||
color: string,
|
||||
width: number,
|
||||
dash: number[],
|
||||
smooth: boolean,
|
||||
): void {
|
||||
// Re-use the SVG-style path string from hexGeometry; Path2D parses the
|
||||
// same `M / L / Q` commands so we don't need to duplicate path math.
|
||||
const d = smooth ? smoothPath(pts) : sharpPath(pts);
|
||||
const path = new Path2D(d);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
if (dash.length > 0) ctx.setLineDash(dash);
|
||||
else ctx.setLineDash([]);
|
||||
ctx.stroke(path);
|
||||
}
|
||||
|
||||
function clamp(v: number, lo: number, hi: number): number {
|
||||
return Math.max(lo, Math.min(hi, v));
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a region's name label centred over its hex cluster, rotated to match
|
||||
* the cluster's principal axis. Ports the algorithm from
|
||||
* `HexMapView.renderRegionOverlay`:
|
||||
*
|
||||
* - Centroid = mean of hex centres
|
||||
* - Angle = `atan2(2*sxy, sxx - syy) / 2` (PCA on covariance matrix)
|
||||
* - Font size scales with √(hexCount), then again with hexRadius/40 so the
|
||||
* label keeps its visual prominence at any export resolution
|
||||
* - White text with a dark stroke for legibility on any base colour
|
||||
*/
|
||||
function drawRegionLabel(
|
||||
ctx: OffscreenCanvasRenderingContext2D,
|
||||
name: string,
|
||||
centers: Array<{ cx: number; cy: number }>,
|
||||
hexRadius: number,
|
||||
): void {
|
||||
const n = centers.length;
|
||||
if (n === 0) return;
|
||||
|
||||
const mx = centers.reduce((s, p) => s + p.cx, 0) / n;
|
||||
const my = centers.reduce((s, p) => s + p.cy, 0) / n;
|
||||
|
||||
let angleDeg = 0;
|
||||
if (n > 1) {
|
||||
const sxx = centers.reduce((s, p) => s + (p.cx - mx) ** 2, 0);
|
||||
const syy = centers.reduce((s, p) => s + (p.cy - my) ** 2, 0);
|
||||
const sxy = centers.reduce((s, p) => s + (p.cx - mx) * (p.cy - my), 0);
|
||||
let ang = (Math.atan2(2 * sxy, sxx - syy) * 180) / Math.PI / 2;
|
||||
if (ang > 90) ang -= 180;
|
||||
if (ang < -90) ang += 180;
|
||||
angleDeg = ang;
|
||||
}
|
||||
|
||||
const baseFont = Math.min(10 + 3 * Math.sqrt(n), 52);
|
||||
// Scale font with hexRadius so labels keep their on-screen weight when
|
||||
// exporting at high resolutions. R=40 matches the on-screen calibration.
|
||||
const fontSize = Math.max(12, Math.round(baseFont * (hexRadius / 40)));
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(mx, my);
|
||||
ctx.rotate((angleDeg * Math.PI) / 180);
|
||||
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
// Dark outline → white fill = readable across light and dark backgrounds.
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineWidth = Math.max(2, fontSize * 0.18);
|
||||
ctx.strokeStyle = "rgba(0, 0, 0, 0.7)";
|
||||
ctx.strokeText(name, 0, 0);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillText(name, 0, 0);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function sortedEntries(m: Map<string, string>): Array<[string, string]> {
|
||||
return Array.from(m.entries()).sort((a, b) =>
|
||||
a[0].localeCompare(b[0], undefined, { sensitivity: "base" }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw (or measure) a legend box. When `measureOnly` is true, no pixels are
|
||||
* touched and only the bounding-box size is returned — useful for laying out
|
||||
* multiple stacked legends bottom-up.
|
||||
*/
|
||||
function drawLegend(
|
||||
ctx: OffscreenCanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
title: string,
|
||||
entries: Array<[string, string]>,
|
||||
fontSize: number,
|
||||
pad: number,
|
||||
measureOnly: boolean,
|
||||
): { width: number; height: number } {
|
||||
if (entries.length === 0) return { width: 0, height: 0 };
|
||||
|
||||
const lineHeight = Math.round(fontSize * 1.45);
|
||||
const swatch = Math.round(fontSize * 1.1);
|
||||
const gap = Math.round(fontSize * 0.5);
|
||||
|
||||
// Measure widths. We need to set the font for accurate measurements.
|
||||
ctx.save();
|
||||
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||
const titleWidth = ctx.measureText(title).width;
|
||||
ctx.font = `${fontSize}px sans-serif`;
|
||||
let widestEntry = 0;
|
||||
for (const [name] of entries) {
|
||||
const w = ctx.measureText(name).width;
|
||||
if (w > widestEntry) widestEntry = w;
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
const contentWidth = Math.ceil(
|
||||
Math.max(titleWidth, swatch + gap + widestEntry),
|
||||
);
|
||||
const width = contentWidth + pad * 2;
|
||||
// Title + entries, each on its own line.
|
||||
const height = pad * 2 + lineHeight + lineHeight * entries.length;
|
||||
|
||||
if (measureOnly) return { width, height };
|
||||
|
||||
// Background panel — readable on both dark terrain and bright overlays.
|
||||
ctx.save();
|
||||
ctx.fillStyle = "rgba(20, 20, 20, 0.86)";
|
||||
ctx.fillRect(x, y, width, height);
|
||||
ctx.strokeStyle = "#555";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x + 0.5, y + 0.5, width - 1, height - 1);
|
||||
|
||||
ctx.textBaseline = "top";
|
||||
ctx.textAlign = "left";
|
||||
|
||||
// Title row.
|
||||
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillText(title, x + pad, y + pad);
|
||||
|
||||
// Entry rows.
|
||||
ctx.font = `${fontSize}px sans-serif`;
|
||||
let entryY = y + pad + lineHeight;
|
||||
for (const [name, color] of entries) {
|
||||
// Swatch — vertically centred on the text baseline.
|
||||
const swatchY = entryY + Math.round((lineHeight - swatch) / 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x + pad, swatchY, swatch, swatch);
|
||||
ctx.strokeStyle = "#888";
|
||||
ctx.strokeRect(x + pad + 0.5, swatchY + 0.5, swatch - 1, swatch - 1);
|
||||
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillText(name, x + pad + swatch + gap, entryY);
|
||||
entryY += lineHeight;
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill a hex polygon with a colour at the given opacity. Used for overlay
|
||||
* tints; doesn't stroke a border (the base hex's border is already drawn).
|
||||
*/
|
||||
function fillHexPolygon(
|
||||
ctx: OffscreenCanvasRenderingContext2D,
|
||||
cx: number,
|
||||
cy: number,
|
||||
radius: number,
|
||||
orientation: "flat" | "pointy",
|
||||
color: string,
|
||||
opacity: number,
|
||||
): void {
|
||||
const pts = hexPolygonPoints(cx, cy, orientation, radius);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0].x, pts[0].y);
|
||||
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = color;
|
||||
const prev = ctx.globalAlpha;
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = prev;
|
||||
}
|
||||
|
||||
// ── Overlay helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Walk every markdown file in the factions folder, read its `faction-color`
|
||||
* frontmatter, return a map of basename → colour.
|
||||
*/
|
||||
function buildFactionColorMap(plugin: HexmakerPlugin): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
const folder = normalizeFolder(plugin.settings.factionsFolder ?? "");
|
||||
for (const f of plugin.app.vault.getMarkdownFiles()) {
|
||||
if (folder && !f.path.startsWith(folder + "/")) continue;
|
||||
if (f.basename.startsWith("_")) continue;
|
||||
const color = getFactionColorFromFile(plugin.app, f.path);
|
||||
if (color) out.set(f.basename, color);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every markdown file in the regions folder, read its `region-color`
|
||||
* frontmatter, return a map of basename → colour.
|
||||
*/
|
||||
function buildRegionColorMap(plugin: HexmakerPlugin): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
const folder = normalizeFolder(plugin.settings.regionsFolder ?? "");
|
||||
for (const f of plugin.app.vault.getMarkdownFiles()) {
|
||||
if (folder && !f.path.startsWith(folder + "/")) continue;
|
||||
if (f.basename.startsWith("_")) continue;
|
||||
const color = getRegionColorFromFile(plugin.app, f.path);
|
||||
if (color) out.set(f.basename, color);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the wiki-link basenames listed under `### Factions` in a hex note,
|
||||
* using the metadata cache directly (no DOM dependency).
|
||||
*/
|
||||
function getHexFactionLinks(app: App, file: TFile): string[] {
|
||||
const cache = app.metadataCache.getFileCache(file);
|
||||
if (!cache) return [];
|
||||
const headings = cache.headings ?? [];
|
||||
const factionHeading = headings.find(
|
||||
(h) => h.heading === "Factions" && h.level === 3,
|
||||
);
|
||||
if (!factionHeading) return [];
|
||||
const factionStart = factionHeading.position.start.offset;
|
||||
const nextHeading = headings.find(
|
||||
(h) => h.position.start.offset > factionStart && h.level <= 3,
|
||||
);
|
||||
const factionEnd = nextHeading?.position.start.offset ?? Infinity;
|
||||
return (cache.links ?? [])
|
||||
.filter(
|
||||
(lk) =>
|
||||
lk.position.start.offset > factionStart &&
|
||||
lk.position.start.offset < factionEnd,
|
||||
)
|
||||
.map((lk) => {
|
||||
const target = lk.link.split("|")[0].split("#")[0].trim();
|
||||
const slash = target.lastIndexOf("/");
|
||||
const stem = slash >= 0 ? target.slice(slash + 1) : target;
|
||||
return stem.replace(/\.md$/i, "");
|
||||
});
|
||||
}
|
||||
|
||||
/** Read the `region:` frontmatter of a hex note via metadata cache. */
|
||||
function getHexRegionName(app: App, file: TFile): string | null {
|
||||
const cache = app.metadataCache.getFileCache(file);
|
||||
const region: unknown = cache?.frontmatter?.["region"];
|
||||
return typeof region === "string" ? region : null;
|
||||
}
|
||||
|
||||
function dashFor(style: "solid" | "dashed" | "dotted"): number[] {
|
||||
switch (style) {
|
||||
case "dashed":
|
||||
return [12, 8];
|
||||
case "dotted":
|
||||
return [2, 6];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Icon loading ───────────────────────────────────────────────────────────
|
||||
|
||||
async function loadIcons(
|
||||
plugin: HexmakerPlugin,
|
||||
names: Set<string>,
|
||||
): Promise<Map<string, HTMLImageElement>> {
|
||||
const cache = new Map<string, HTMLImageElement>();
|
||||
const tasks: Promise<void>[] = [];
|
||||
for (const name of names) {
|
||||
const url = getIconUrl(plugin, name);
|
||||
tasks.push(
|
||||
loadImage(url)
|
||||
.then((img) => {
|
||||
cache.set(name, img);
|
||||
})
|
||||
.catch(() => {
|
||||
console.warn(`Map export: failed to load icon ${name}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
return cache;
|
||||
}
|
||||
|
||||
function loadImage(url: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Public exporter (used by the MapModal Export tab) ──────────────────────
|
||||
|
||||
/**
|
||||
* Options for the `exportMapAsPng` wrapper. Extends pure rendering options
|
||||
* with file-naming concerns the wrapper layer cares about.
|
||||
*/
|
||||
export interface MapPngExportOptions extends MapPngRenderOptions {
|
||||
/**
|
||||
* Filename stem to write to the export folder (no extension). Defaults to
|
||||
* `mapName`. Sanitised to strip vault-illegal characters.
|
||||
*/
|
||||
outputName?: string;
|
||||
}
|
||||
|
||||
export async function exportMapAsPng(
|
||||
plugin: HexmakerPlugin,
|
||||
mapName: string,
|
||||
opts: MapPngExportOptions = {},
|
||||
): Promise<void> {
|
||||
const folder = await ensureExportFolder(plugin);
|
||||
const stem = (opts.outputName ?? mapName).trim() || mapName;
|
||||
const outPath = `${folder}/${sanitiseFilename(stem)}.png`;
|
||||
|
||||
const notice = new Notice(`Exporting ${mapName}.png…`, 0);
|
||||
try {
|
||||
const blob = await renderMapToPngBlob(plugin, mapName, opts);
|
||||
const buf = new Uint8Array(await blob.arrayBuffer());
|
||||
await writeBinaryToVault(plugin, outPath, buf);
|
||||
new Notice(`Exported to ${outPath}`);
|
||||
void openInVault(plugin, outPath);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
new Notice(`Map export failed: ${(err as Error).message ?? err}`);
|
||||
} finally {
|
||||
notice.hide();
|
||||
}
|
||||
}
|
||||
|
||||
function sanitiseFilename(name: string): string {
|
||||
// Strip vault-illegal characters from the map name when used as a filename.
|
||||
return name.replace(/[\\/:*?"<>|]/g, "_");
|
||||
}
|
||||
|
||||
async function writeBinaryToVault(
|
||||
plugin: HexmakerPlugin,
|
||||
path: string,
|
||||
data: Uint8Array,
|
||||
): Promise<void> {
|
||||
const buf = data.buffer.slice(
|
||||
data.byteOffset,
|
||||
data.byteOffset + data.byteLength,
|
||||
) as ArrayBuffer;
|
||||
const existing = plugin.app.vault.getAbstractFileByPath(path);
|
||||
if (existing instanceof TFile) {
|
||||
await plugin.app.vault.modifyBinary(existing, buf);
|
||||
} else {
|
||||
await plugin.app.vault.createBinary(path, buf);
|
||||
}
|
||||
}
|
||||
|
||||
async function openInVault(
|
||||
plugin: HexmakerPlugin,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
const file = plugin.app.vault.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
await plugin.app.workspace.getLeaf(false).openFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export so the renderer's surface is self-contained.
|
||||
export type { MapData };
|
||||
66
src/export/mdSerializer.ts
Normal file
66
src/export/mdSerializer.ts
Normal 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
158
src/export/pdfExporter.ts
Normal 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
217
src/export/printScaffold.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* Print-specific CSS patches. Forces white background, sensible margins, sane
|
||||
* page-break behaviour around tables, and a printable title style.
|
||||
*
|
||||
* Applied on TOP of whatever theme CSS was captured at runtime — its job is
|
||||
* to override any dark/unreadable theme rules and add print-specific
|
||||
* formatting (page breaks, table layout).
|
||||
*/
|
||||
export const PRINT_PATCH_CSS = `
|
||||
/* duckmage export print patches */
|
||||
html, body {
|
||||
background: #ffffff !important;
|
||||
color: #111111 !important;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body.theme-light {
|
||||
--background-primary: #ffffff;
|
||||
--text-normal: #111111;
|
||||
}
|
||||
.print {
|
||||
padding: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
.print .markdown-preview-view,
|
||||
.print .markdown-rendered {
|
||||
background: #ffffff !important;
|
||||
color: #111111 !important;
|
||||
padding: 0.5in 0.6in;
|
||||
max-width: none;
|
||||
height: auto !important;
|
||||
}
|
||||
/* Title style — applies to the markdown's leading h1 inside the print scaffold */
|
||||
.print h1:first-child {
|
||||
font-size: 1.7em;
|
||||
margin: 0 0 0.6em 0;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 2px solid #333;
|
||||
}
|
||||
|
||||
/* Constrain images so a huge source asset doesn't blow out a page.
|
||||
max-height of 4in (~ 40% of a Letter page) keeps the heading + image +
|
||||
caption able to share a page comfortably, while still showing the image
|
||||
at a useful size for a printed game aid. Small images render at their
|
||||
natural size (width: auto, no min sizing). */
|
||||
.print img {
|
||||
max-width: 100%;
|
||||
max-height: 4in;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Map-PDF specific overrides: the cover-page map fills the page; the
|
||||
section-page map sits at the top in landscape mode and gets ~ 40% of
|
||||
the available height. */
|
||||
.print img.duckmage-pdf-fullmap {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 6.5in;
|
||||
margin: 0 auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
.print img.duckmage-pdf-sectionmap {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 3.6in;
|
||||
margin: 0 auto 0.6em auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Explicit page-break marker used by the map PDF exporter. The empty div
|
||||
forces the renderer to start a new page even when the previous content
|
||||
leaves room. */
|
||||
.print .duckmage-pdf-pagebreak {
|
||||
break-before: page;
|
||||
page-break-before: always;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Workflow sample blocks. Each sampled output gets its own visually
|
||||
separated block. */
|
||||
.print .duckmage-workflow-sample {
|
||||
margin-top: 1.4em;
|
||||
padding-top: 1em;
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
.print .duckmage-workflow-sample:first-of-type {
|
||||
margin-top: 1em;
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
.print .duckmage-workflow-sample > h3 {
|
||||
margin-top: 0;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.print .duckmage-workflow-sample table {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
/* Single-hex export sections. Each section gets generous breathing room
|
||||
and a subtle top border for visual separation. The first section is
|
||||
borderless so it doesn't double up with the metadata header above. */
|
||||
.print .duckmage-hex-section {
|
||||
margin-top: 1.6em;
|
||||
padding-top: 1.2em;
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
.print .duckmage-hex-section:first-of-type {
|
||||
margin-top: 1.2em;
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
.print .duckmage-hex-section > h2 {
|
||||
margin-top: 0;
|
||||
font-size: 1.25em;
|
||||
}
|
||||
.print .duckmage-hex-section ul {
|
||||
margin: 0.4em 0 0.6em 1.3em;
|
||||
}
|
||||
.print .duckmage-hex-section p {
|
||||
line-height: 1.5;
|
||||
margin: 0.4em 0 0.6em 0;
|
||||
}
|
||||
|
||||
/* Each linked-note entry in the appendix. Visual separation via top border;
|
||||
break control via break-inside: avoid-page (in @media print below). */
|
||||
.print .duckmage-export-note {
|
||||
border-top: 1px solid #ddd;
|
||||
margin-top: 1.4em;
|
||||
padding-top: 1em;
|
||||
}
|
||||
.print .duckmage-export-note-first {
|
||||
border-top: 2px solid #999;
|
||||
margin-top: 2em;
|
||||
padding-top: 1.4em;
|
||||
}
|
||||
.print .duckmage-export-note > h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@media print {
|
||||
table { break-inside: auto; width: 100%; border-collapse: collapse; }
|
||||
thead { display: table-header-group; }
|
||||
tr { break-inside: avoid; break-after: auto; }
|
||||
th, td {
|
||||
border: 1px solid #ccc;
|
||||
padding: 4px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
/* Keep headings tied to what follows them. break-after on the heading
|
||||
itself + break-before:avoid on the immediate next sibling. */
|
||||
h1, h2, h3, h4 { break-after: avoid; }
|
||||
h2 + *, h3 + *, h4 + * { break-before: avoid; }
|
||||
|
||||
/* Paragraph orphan/widow control. */
|
||||
p { orphans: 3; widows: 3; }
|
||||
|
||||
/* Images shouldn't split across pages. */
|
||||
img, figure { break-inside: avoid; }
|
||||
|
||||
/* Tighter table cells for dense map-PDF reference tables (many columns
|
||||
on a landscape page) so all hex link sections fit without wrapping
|
||||
awkwardly. */
|
||||
.print table { font-size: 0.78em; }
|
||||
.print th, .print td { padding: 3px 5px; }
|
||||
|
||||
/* Each linked-note entry: keep heading + body together. If the entire
|
||||
entry doesn't fit on the remaining page, the browser starts it on a
|
||||
new page. Entries larger than one page will split, which is fine —
|
||||
this only prevents the avoidable orphan case. */
|
||||
.duckmage-export-note { break-inside: avoid-page; }
|
||||
/* Same treatment for single-hex export sections. */
|
||||
.duckmage-hex-section { break-inside: avoid-page; }
|
||||
/* And for workflow sample blocks. */
|
||||
.duckmage-workflow-sample { break-inside: avoid-page; }
|
||||
}
|
||||
`;
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
115
src/hex-map/HexExportModal.ts
Normal file
115
src/hex-map/HexExportModal.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
145
src/random-tables/WorkflowExportModal.ts
Normal file
145
src/random-tables/WorkflowExportModal.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -94,6 +94,7 @@ export interface HexmakerPluginSettings {
|
|||
defaultSubmapCols: number;
|
||||
defaultSubmapRows: number;
|
||||
workflowsFolder: string;
|
||||
exportFolder: string;
|
||||
hiddenIcons: string[];
|
||||
iconOrder: string[];
|
||||
setupComplete: boolean;
|
||||
|
|
|
|||
78
styles.css
78
styles.css
|
|
@ -1231,6 +1231,63 @@ input.duckmage-input-error {
|
|||
left: 44px;
|
||||
}
|
||||
|
||||
/* ── Export tab in the Maps modal ─────────────────────────────────────────── */
|
||||
.duckmage-export-tab {
|
||||
padding: 0.5em 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8em;
|
||||
}
|
||||
.duckmage-export-tab-hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
margin: 0;
|
||||
}
|
||||
.duckmage-export-tab-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4em;
|
||||
}
|
||||
.duckmage-export-tab-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
padding: 0.2em 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.duckmage-export-tab-label {
|
||||
cursor: pointer;
|
||||
}
|
||||
.duckmage-export-tab-number {
|
||||
width: 80px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.duckmage-export-tab-text {
|
||||
flex: 1;
|
||||
margin-left: 0.5em;
|
||||
min-width: 0;
|
||||
}
|
||||
.duckmage-export-tab-select {
|
||||
flex: 1;
|
||||
margin-left: 0.5em;
|
||||
min-width: 0;
|
||||
}
|
||||
.duckmage-export-tab-preview {
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
padding: 0.3em 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
.duckmage-export-tab-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5em;
|
||||
padding-top: 0.5em;
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.duckmage-undo-btn-map {
|
||||
left: 8px;
|
||||
top: 42px;
|
||||
|
|
@ -4587,3 +4644,24 @@ tr:hover .duckmage-rt-entry-copy-btn:hover {
|
|||
color: var(--text-on-accent);
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
/* Export — hidden render host for printToPDF */
|
||||
.duckmage-export-webview {
|
||||
position: fixed;
|
||||
left: -10000px;
|
||||
top: -10000px;
|
||||
width: 850px;
|
||||
height: 1100px;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Export — off-screen render host for MarkdownRenderer */
|
||||
.duckmage-export-render-host {
|
||||
position: fixed;
|
||||
left: -10000px;
|
||||
top: -10000px;
|
||||
width: 850px;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
|
|
|||
60
tests/fixtures/sample-hex.md
vendored
Normal file
60
tests/fixtures/sample-hex.md
vendored
Normal 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
15
tests/fixtures/sample-random-table.md
vendored
Normal 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
21
tests/fixtures/sample-town.md
vendored
Normal 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
55
tests/fixtures/sim-vault/README.md
vendored
Normal 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.
|
||||
3
tests/fixtures/sim-vault/dungeons/The Old Spire.md
vendored
Normal file
3
tests/fixtures/sim-vault/dungeons/The Old Spire.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
### Overview
|
||||
|
||||
A crumbling watchtower of black stone, half-submerged at high tide.
|
||||
5
tests/fixtures/sim-vault/factions/The Reach.md
vendored
Normal file
5
tests/fixtures/sim-vault/factions/The Reach.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
faction-color: "#cd5c5c"
|
||||
---
|
||||
|
||||
A coastal merchant alliance claiming most of the southern trade routes.
|
||||
1
tests/fixtures/sim-vault/features/Standing Stone.md
vendored
Normal file
1
tests/fixtures/sim-vault/features/Standing Stone.md
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
A weather-worn standing stone, taller than two riders.
|
||||
43
tests/fixtures/sim-vault/hexes/the-coast/0_0.md
vendored
Normal file
43
tests/fixtures/sim-vault/hexes/the-coast/0_0.md
vendored
Normal 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]]
|
||||
8
tests/fixtures/sim-vault/hexes/the-coast/0_1.md
vendored
Normal file
8
tests/fixtures/sim-vault/hexes/the-coast/0_1.md
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
terrain: ocean
|
||||
region: Coastlands
|
||||
---
|
||||
|
||||
### Description
|
||||
|
||||
Open ocean stretching to the southern horizon.
|
||||
16
tests/fixtures/sim-vault/hexes/the-coast/1_0.md
vendored
Normal file
16
tests/fixtures/sim-vault/hexes/the-coast/1_0.md
vendored
Normal 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]]
|
||||
15
tests/fixtures/sim-vault/hexes/the-coast/1_1.md
vendored
Normal file
15
tests/fixtures/sim-vault/hexes/the-coast/1_1.md
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
terrain: grass
|
||||
---
|
||||
|
||||
### Description
|
||||
|
||||
Rolling pastureland.
|
||||
|
||||
### Towns
|
||||
|
||||
- [[Saltwatch]]
|
||||
|
||||
### Factions
|
||||
|
||||
- [[The Reach]]
|
||||
3
tests/fixtures/sim-vault/hexes/the-coast/2_0.md
vendored
Normal file
3
tests/fixtures/sim-vault/hexes/the-coast/2_0.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
terrain: mountain
|
||||
---
|
||||
3
tests/fixtures/sim-vault/hexes/the-coast/2_1.md
vendored
Normal file
3
tests/fixtures/sim-vault/hexes/the-coast/2_1.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
terrain: hills
|
||||
---
|
||||
1
tests/fixtures/sim-vault/quests/Find the Wanderer.md
vendored
Normal file
1
tests/fixtures/sim-vault/quests/Find the Wanderer.md
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
Recover a missing scout last seen heading into the [[Standing Stone]] hex.
|
||||
5
tests/fixtures/sim-vault/regions/Coastlands.md
vendored
Normal file
5
tests/fixtures/sim-vault/regions/Coastlands.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
region-color: "#6e8eb1"
|
||||
---
|
||||
|
||||
The narrow coastal strip from Saltwatch south to the cliffs.
|
||||
12
tests/fixtures/sim-vault/tables/forest-encounters.md
vendored
Normal file
12
tests/fixtures/sim-vault/tables/forest-encounters.md
vendored
Normal 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 |
|
||||
11
tests/fixtures/sim-vault/tables/npc-names.md
vendored
Normal file
11
tests/fixtures/sim-vault/tables/npc-names.md
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
---
|
||||
dice: 20
|
||||
---
|
||||
|
||||
| Result | Weight |
|
||||
|--------|--------|
|
||||
| Bromund | 1 |
|
||||
| Lirel | 1 |
|
||||
| Vargax | 1 |
|
||||
| Saskia | 1 |
|
||||
| Olwen | 1 |
|
||||
11
tests/fixtures/sim-vault/tables/treasure.md
vendored
Normal file
11
tests/fixtures/sim-vault/tables/treasure.md
vendored
Normal 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 |
|
||||
1
tests/fixtures/sim-vault/tables/treasure/cursed amulet.md
vendored
Normal file
1
tests/fixtures/sim-vault/tables/treasure/cursed amulet.md
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
Black iron amulet that whispers at midnight. **Wearer becomes restless.**
|
||||
1
tests/fixtures/sim-vault/tables/treasure/gold pile.md
vendored
Normal file
1
tests/fixtures/sim-vault/tables/treasure/gold pile.md
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
A heap of unminted gold ingots wrapped in a moldering cloak.
|
||||
1
tests/fixtures/sim-vault/tables/treasure/silver ring.md
vendored
Normal file
1
tests/fixtures/sim-vault/tables/treasure/silver ring.md
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
A silver ring etched with a *running stag*. Slightly tarnished.
|
||||
12
tests/fixtures/sim-vault/towns/Saltwatch.md
vendored
Normal file
12
tests/fixtures/sim-vault/towns/Saltwatch.md
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
population: 320
|
||||
---
|
||||
|
||||
### Overview
|
||||
|
||||
A small fishing town clinging to the cliffs.
|
||||
|
||||
### Notable Residents
|
||||
|
||||
- **Maren Kell** — harbourmaster.
|
||||
- **Old Tomeas** — smith.
|
||||
11
tests/fixtures/sim-vault/workflows/npc-generator.md
vendored
Normal file
11
tests/fixtures/sim-vault/workflows/npc-generator.md
vendored
Normal 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 |
|
||||
3
tests/fixtures/sim-vault/workflows/templates/npc-template.md
vendored
Normal file
3
tests/fixtures/sim-vault/workflows/templates/npc-template.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# $name
|
||||
|
||||
A traveller with **HP $hp** wanders the road.
|
||||
423
tests/helpers/simVault.ts
Normal file
423
tests/helpers/simVault.ts
Normal 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
138
tests/hexGeometry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
130
tests/integration/e2e-mapPdf.integration.ts
Normal file
130
tests/integration/e2e-mapPdf.integration.ts
Normal 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 \|/);
|
||||
});
|
||||
});
|
||||
103
tests/integration/e2e-randomTable.integration.ts
Normal file
103
tests/integration/e2e-randomTable.integration.ts
Normal 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;
|
||||
124
tests/integration/e2e-singleHex.integration.ts
Normal file
124
tests/integration/e2e-singleHex.integration.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
65
tests/integration/e2e-singleNote.integration.ts
Normal file
65
tests/integration/e2e-singleNote.integration.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
130
tests/integration/e2e-workflow.integration.ts
Normal file
130
tests/integration/e2e-workflow.integration.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
260
tests/integration/mapWithTableExport.integration.ts
Normal file
260
tests/integration/mapWithTableExport.integration.ts
Normal 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, """)}" 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("");
|
||||
}
|
||||
405
tests/integration/randomTableExport.integration.ts
Normal file
405
tests/integration/randomTableExport.integration.ts
Normal 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
|
||||
* md→html converter for the table subset we generate
|
||||
* - The user's installed theme inheritance (document.styleSheets capture)
|
||||
* - The webview lifecycle inside Obsidian's renderer process
|
||||
*
|
||||
* Engine note: Chromium's printToPDF (driven via Puppeteer in
|
||||
* chromium-driver) is the same engine Obsidian's <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 **6–10 grey wolves** stalking the party. They strike at dusk and fade into the trees if injured.",
|
||||
},
|
||||
{
|
||||
name: "Bandit ambush",
|
||||
content:
|
||||
"A roadside ambush by *desperate* highway robbers. They demand coin first; fight only if pushed.",
|
||||
},
|
||||
{
|
||||
name: "Druid circle",
|
||||
content:
|
||||
"A solemn gathering of three druids in a moonlit clearing. They speak the language of birds.",
|
||||
},
|
||||
];
|
||||
const appendix = formatLinkedNotesAppendix(linkedNotes);
|
||||
expect(appendix.length).toBeGreaterThan(0);
|
||||
const fullMd = `${baseMd}\n\n${appendix}`;
|
||||
const html = wrapInFullHtml(fullMd, "Sample wandering monsters (with linked notes)");
|
||||
|
||||
const pdfPath = path.join(tmpDir, "sample-with-notes.pdf");
|
||||
const { htmlToPdf } = await import(`${CHROMIUM_DRIVER}/scripts/html-to-pdf.mjs`);
|
||||
await htmlToPdf(html, pdfPath, { pageSize: "Letter" });
|
||||
|
||||
// PDF valid + has multiple pages OR enough content
|
||||
const info = execSync(`pdfinfo "${pdfPath}"`).toString();
|
||||
expect(info).toMatch(/PDF version:/);
|
||||
|
||||
const text = execSync(`pdftotext "${pdfPath}" -`).toString();
|
||||
|
||||
// Table entries (from base) still present
|
||||
expect(text).toMatch(/Pack of wolves/);
|
||||
expect(text).toMatch(/Bandit ambush/);
|
||||
expect(text).toMatch(/Druid circle/);
|
||||
|
||||
// Linked-note content present. Use a normalized form that collapses
|
||||
// whitespace (pdftotext line-wraps inside paragraphs).
|
||||
const flat = text.replace(/\s+/g, " ");
|
||||
expect(flat).toMatch(/grey wolves/);
|
||||
expect(flat).toMatch(/highway robbers/);
|
||||
expect(flat).toMatch(/moonlit clearing/);
|
||||
expect(flat).toMatch(/language of birds/);
|
||||
|
||||
// Entry order preserved in PDF text
|
||||
const idxWolves = flat.indexOf("grey wolves");
|
||||
const idxBandits = flat.indexOf("highway robbers");
|
||||
const idxDruids = flat.indexOf("moonlit clearing");
|
||||
expect(idxWolves).toBeGreaterThan(-1);
|
||||
expect(idxBandits).toBeGreaterThan(idxWolves);
|
||||
expect(idxDruids).toBeGreaterThan(idxBandits);
|
||||
|
||||
// Persist for visual inspection
|
||||
const pngStem = path.join(tmpDir, "page-notes");
|
||||
execSync(`pdftoppm -png -r 96 -f 1 -l 2 "${pdfPath}" "${pngStem}"`);
|
||||
const persistPath = path.resolve(__dirname, "../../.integration-out");
|
||||
await fs.mkdir(persistPath, { recursive: true });
|
||||
await fs.copyFile(pdfPath, path.join(persistPath, "sample-with-linked-notes.pdf"));
|
||||
// Copy whatever page PNGs were produced.
|
||||
for (const pageFile of await fs.readdir(tmpDir)) {
|
||||
if (pageFile.startsWith("page-notes-") && pageFile.endsWith(".png")) {
|
||||
await fs.copyFile(
|
||||
path.join(tmpDir, pageFile),
|
||||
path.join(persistPath, `sample-with-linked-notes-${pageFile.slice("page-notes-".length)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("emits no appendix when no entries resolve", () => {
|
||||
expect(formatLinkedNotesAppendix([])).toBe("");
|
||||
});
|
||||
|
||||
// Stress test: long notes + an oversized image. Without proper CSS, this
|
||||
// exposes (a) images blowing past the page width and (b) section headings
|
||||
// becoming orphaned at the bottom of a page with their content on the next.
|
||||
it("keeps headings with content and constrains image size on multi-page output", async () => {
|
||||
const baseMd = await formatFixture();
|
||||
|
||||
// A 1200x900 SVG — deliberately larger than the printable column width
|
||||
// (Letter at 0.6" left + 0.6" right margin ≈ 7.3" wide ≈ 700px @ 96dpi).
|
||||
// If the CSS doesn't constrain it, the image will overflow horribly.
|
||||
const bigImgSvgRaw =
|
||||
`<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 **6–10 grey wolves** stalking the party. They strike at dusk and fade into the trees if injured.",
|
||||
},
|
||||
{
|
||||
name: "Lost merchant",
|
||||
content:
|
||||
`\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 markdown→HTML converter for Obsidian's MarkdownRenderer since this
|
||||
* test runs outside Obsidian.
|
||||
*/
|
||||
function wrapInFullHtml(markdown: string, title: string): string {
|
||||
const inner = miniMarkdownToHtml(markdown);
|
||||
const body = wrapInPrintScaffold(title, inner);
|
||||
return `<!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 markdown→HTML for the subset our exporter emits:
|
||||
* - `# heading`
|
||||
* - `*italic*`
|
||||
* - markdown tables (`|cell|cell|` with separator row)
|
||||
* - plain paragraphs
|
||||
*
|
||||
* NOT a general markdown renderer. Real runtime uses Obsidian's renderer.
|
||||
*/
|
||||
function miniMarkdownToHtml(md: string): string {
|
||||
const lines = md.split("\n");
|
||||
const out: string[] = [];
|
||||
let inTable = false;
|
||||
let headerEmitted = false;
|
||||
|
||||
const closeTable = () => {
|
||||
if (inTable) {
|
||||
out.push("</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.
|
||||
//  — both alt and url
|
||||
return s
|
||||
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) =>
|
||||
`<img src="${url.replace(/"/g, """)}" 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("");
|
||||
}
|
||||
313
tests/integration/singleHexExport.integration.ts
Normal file
313
tests/integration/singleHexExport.integration.ts
Normal 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("");
|
||||
}
|
||||
209
tests/integration/singleNoteExport.integration.ts
Normal file
209
tests/integration/singleNoteExport.integration.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* Integration test for the single-note PDF export pipeline (Phase 1.2).
|
||||
*
|
||||
* Pipeline:
|
||||
* read fixture → stripFrontmatter → ensureLeadingTitle → mini md→html →
|
||||
* wrap in print scaffold → chromium-driver htmlToPdf → pdfinfo + pdftotext
|
||||
*
|
||||
* What this validates:
|
||||
* - Frontmatter is stripped (tags/population shouldn't appear in PDF)
|
||||
* - Title is the file basename (prepended because the fixture has no H1)
|
||||
* - All section headings + bullets render
|
||||
* - Bold/italic survive the pipeline
|
||||
*
|
||||
* What it doesn't (would need Obsidian runtime):
|
||||
* - Theme inheritance from document.styleSheets
|
||||
* - Wikilink resolution to clickable anchors
|
||||
*/
|
||||
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import expect from "expect";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
import { ensureLeadingTitle } from "../../src/export/exporters/singleNote";
|
||||
import { PRINT_PATCH_CSS, wrapInPrintScaffold, escapeHtml } from "../../src/export/printScaffold";
|
||||
|
||||
const CHROMIUM_DRIVER = "/mnt/c/Users/markr/work/tools/chromium-driver";
|
||||
const FIXTURE = path.resolve(__dirname, "../fixtures/sample-town.md");
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
before(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "duckmage-single-itest-"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("single note PDF export — integration", () => {
|
||||
it("renders the fixture cleanly with stripped frontmatter and prepended title", async () => {
|
||||
const raw = await fs.readFile(FIXTURE, "utf8");
|
||||
const md = ensureLeadingTitle(raw, "Saltwatch");
|
||||
const html = wrapInFullHtml(md, "Saltwatch");
|
||||
|
||||
const pdfPath = path.join(tmpDir, "saltwatch.pdf");
|
||||
const { htmlToPdf } = await import(`${CHROMIUM_DRIVER}/scripts/html-to-pdf.mjs`);
|
||||
const result = await htmlToPdf(html, pdfPath, { pageSize: "Letter" });
|
||||
expect(result.sizeBytes).toBeGreaterThan(3000);
|
||||
|
||||
const info = execSync(`pdfinfo "${pdfPath}"`).toString();
|
||||
expect(info).toMatch(/PDF version:/);
|
||||
|
||||
const text = execSync(`pdftotext "${pdfPath}" -`).toString();
|
||||
|
||||
// Title (prepended because fixture has no H1)
|
||||
expect(text).toMatch(/Saltwatch/);
|
||||
|
||||
// Section headings
|
||||
expect(text).toMatch(/Overview/);
|
||||
expect(text).toMatch(/Notable Residents/);
|
||||
expect(text).toMatch(/Hooks & Rumors/);
|
||||
|
||||
// Content fragments unique to each section
|
||||
expect(text).toMatch(/southern cliffs/);
|
||||
expect(text).toMatch(/Maren Kell/);
|
||||
expect(text).toMatch(/lighthouse burns blue/);
|
||||
|
||||
// Frontmatter must NOT leak into the rendered text
|
||||
expect(text).not.toMatch(/tags:\s*\[/);
|
||||
expect(text).not.toMatch(/population:\s*320/);
|
||||
|
||||
// Persist for visual inspection
|
||||
const pngStem = path.join(tmpDir, "page");
|
||||
execSync(`pdftoppm -png -r 96 -f 1 -l 1 "${pdfPath}" "${pngStem}"`);
|
||||
const persistPath = path.resolve(__dirname, "../../.integration-out");
|
||||
await fs.mkdir(persistPath, { recursive: true });
|
||||
await fs.copyFile(pdfPath, path.join(persistPath, "single-note.pdf"));
|
||||
await fs.copyFile(`${pngStem}-1.png`, path.join(persistPath, "single-note-page1.png"));
|
||||
});
|
||||
|
||||
it("does not prepend a title when the note already begins with H1", () => {
|
||||
const raw = "# My existing title\n\nSome content.";
|
||||
expect(ensureLeadingTitle(raw, "Other Name")).toBe(raw);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── helpers shared with the random-table integration test ─────────────────
|
||||
|
||||
function wrapInFullHtml(markdown: string, title: string): string {
|
||||
const inner = miniMarkdownToHtml(markdown);
|
||||
const body = wrapInPrintScaffold(title, inner);
|
||||
return `<!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 markdown→HTML for our exporter's emitted subset:
|
||||
* h1/h2/h3, ul (bullets), tables, paragraphs, , `<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, """)}" 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("");
|
||||
}
|
||||
271
tests/integration/workflowExport.integration.ts
Normal file
271
tests/integration/workflowExport.integration.ts
Normal 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, "|");
|
||||
}
|
||||
175
tests/mapWithTableExport.test.ts
Normal file
175
tests/mapWithTableExport.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
249
tests/randomTableExport.test.ts
Normal file
249
tests/randomTableExport.test.ts
Normal 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 → 1–4 and 5–10
|
||||
expect(md).toMatch(/\| 1–4 \| A \| 4 \|/);
|
||||
expect(md).toMatch(/\| 5–10 \| B \| 6 \|/);
|
||||
});
|
||||
|
||||
it("omits the Weight column when all weights are 1", () => {
|
||||
const md = formatRandomTableMarkdown(
|
||||
"T",
|
||||
makeTable({
|
||||
entries: [
|
||||
{ result: "A", weight: 1 },
|
||||
{ result: "B", weight: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(md).not.toMatch(/Weight/);
|
||||
expect(md).toMatch(/\| # \| Result \|/);
|
||||
});
|
||||
|
||||
it("includes the Weight column when any weight is non-1", () => {
|
||||
const md = formatRandomTableMarkdown(
|
||||
"T",
|
||||
makeTable({
|
||||
entries: [
|
||||
{ result: "A", weight: 1 },
|
||||
{ result: "B", weight: 3 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(md).toMatch(/\| # \| Result \| Weight \|/);
|
||||
expect(md).toMatch(/\| 1 \| A \| 1 \|/);
|
||||
expect(md).toMatch(/\| 2 \| B \| 3 \|/);
|
||||
});
|
||||
|
||||
it("omits the Odds column entirely", () => {
|
||||
const md = formatRandomTableMarkdown(
|
||||
"T",
|
||||
makeTable({ entries: [{ result: "A", weight: 1 }] }),
|
||||
);
|
||||
expect(md).not.toMatch(/Odds/);
|
||||
expect(md).not.toMatch(/%/);
|
||||
});
|
||||
|
||||
it("escapes pipes inside entry results", () => {
|
||||
const md = formatRandomTableMarkdown(
|
||||
"T",
|
||||
makeTable({
|
||||
entries: [{ result: "left | right", weight: 1 }],
|
||||
}),
|
||||
);
|
||||
expect(md).toMatch(/left \\\| right/);
|
||||
});
|
||||
|
||||
it("uses row index as # column when dice = 0", () => {
|
||||
const md = formatRandomTableMarkdown(
|
||||
"T",
|
||||
makeTable({
|
||||
entries: [
|
||||
{ result: "A", weight: 1 },
|
||||
{ result: "B", weight: 1 },
|
||||
{ result: "C", weight: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(md).toMatch(/\| 1 \| A \|/);
|
||||
expect(md).toMatch(/\| 2 \| B \|/);
|
||||
expect(md).toMatch(/\| 3 \| C \|/);
|
||||
});
|
||||
|
||||
it("renders empty-entries table without crashing", () => {
|
||||
const md = formatRandomTableMarkdown("Empty", makeTable({ entries: [] }));
|
||||
expect(md).toMatch(/# Empty/);
|
||||
// Table header still present
|
||||
expect(md).toMatch(/\| # \| Result \|/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripFrontmatter", () => {
|
||||
it("returns content unchanged when no frontmatter is present", () => {
|
||||
expect(stripFrontmatter("Hello\nworld")).toBe("Hello\nworld");
|
||||
});
|
||||
|
||||
it("strips a basic YAML block", () => {
|
||||
const md = "---\ntitle: Foo\ntags: [a, b]\n---\n\n# Body";
|
||||
expect(stripFrontmatter(md)).toBe("# Body");
|
||||
});
|
||||
|
||||
it("strips with no trailing blank line", () => {
|
||||
const md = "---\ntitle: Foo\n---\n# Body";
|
||||
expect(stripFrontmatter(md)).toBe("# Body");
|
||||
});
|
||||
|
||||
it("strips with CRLF line endings", () => {
|
||||
const md = "---\r\ntitle: Foo\r\n---\r\n\r\n# Body";
|
||||
expect(stripFrontmatter(md)).toBe("# Body");
|
||||
});
|
||||
|
||||
it("does not strip a horizontal rule that isn't frontmatter", () => {
|
||||
const md = "Some text\n\n---\n\nMore text";
|
||||
expect(stripFrontmatter(md)).toBe(md);
|
||||
});
|
||||
|
||||
it("returns content unchanged when frontmatter open is unterminated", () => {
|
||||
const md = "---\ntitle: Foo\nno close\n";
|
||||
expect(stripFrontmatter(md)).toBe(md);
|
||||
});
|
||||
|
||||
it("handles empty frontmatter block", () => {
|
||||
const md = "---\n---\n\n# Body";
|
||||
expect(stripFrontmatter(md)).toBe("# Body");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatLinkedNotesAppendix", () => {
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(formatLinkedNotesAppendix([])).toBe("");
|
||||
});
|
||||
|
||||
it("wraps a single note in a section with the first-entry class", () => {
|
||||
const out = formatLinkedNotesAppendix([
|
||||
{ name: "Goblin Lair", content: "Dark cave entrance." },
|
||||
]);
|
||||
expect(out).toMatch(
|
||||
/<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);
|
||||
});
|
||||
});
|
||||
114
tests/singleHexExport.test.ts
Normal file
114
tests/singleHexExport.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
56
tests/singleNoteExport.test.ts
Normal file
56
tests/singleNoteExport.test.ts
Normal 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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
114
tests/workflowExport.test.ts
Normal file
114
tests/workflowExport.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
Loading…
Reference in a new issue