mirror of
https://github.com/sbuffkin/hexmaker.git
synced 2026-07-22 06:14:01 +00:00
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>
175 lines
5.3 KiB
TypeScript
175 lines
5.3 KiB
TypeScript
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);
|
||
});
|
||
});
|