mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 17:29:29 +05:30
feat: v4.6.0 — AI assistant, collaboration, knowledge base, and 15 more features
- AI Assistant plugin: multi-provider chat (OpenAI/Anthropic/Ollama/LM Studio), summarize/improve/translate commands, proofread via ai:analyze; calls proxied through main so API keys stay out of the renderer - Collaboration plugin: anchor-based comments in .comments/ sidecars with drift detection and F8 navigation - Local knowledge base: [[wiki-links]] with click-to-create + Backlinks panel - Crash recovery: debounced session snapshots with restore prompt on launch - Version history: pre-save snapshots, History panel with restore/diff/delete - Real PDF encryption: swap pdf-lib for @cantoo/pdf-lib (probe-driven UI) - XLSX export (native workbooks via JSZip), ODT headers/footers + page size - Offline KaTeX (bundled CSS+fonts), local-first PlantUML rendering - Editor: vim mode toggle, snippet Tab-expansion, zen word-goal setter, writing heatmap, writing-studio panels wired with rail icons - Quick Note global scratchpad (Ctrl+Alt+Q), markdownconverter:// deep links, REPL first-run confirmation - Fix: Ctrl+Shift+P collision, pandoc converter availability check, CLI dangling --css/--reference-doc flags, dead converter button 8 new test suites; 613 tests green; lint clean
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*
|
||||
* OdtStyling.js tests: page-size patching and header/footer injection into a
|
||||
* Pandoc-style ODT package. Fixtures are minimal zips built with PizZip,
|
||||
* mirroring the structure Pandoc's ODT writer emits (styles.xml with a "pm1"
|
||||
* page layout and a "Standard" master page).
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const PizZip = require('pizzip');
|
||||
const {
|
||||
setOdtPageSize,
|
||||
addHeaderFooterToOdt,
|
||||
parseDimensionsMm,
|
||||
escapeOdtText,
|
||||
} = require('../../src/main/OdtStyling');
|
||||
|
||||
// Minimal Pandoc-like styles.xml: page layout pm1 + Standard master page
|
||||
const STYLES_XML = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
||||
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
|
||||
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
|
||||
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
|
||||
office:version="1.2">
|
||||
<office:automatic-styles>
|
||||
<style:page-layout style:name="pm1">
|
||||
<style:page-layout-properties fo:margin-top="0.787in" fo:page-width="8.267in" fo:page-height="11.69in" style:print-orientation="portrait"/>
|
||||
</style:page-layout>
|
||||
</office:automatic-styles>
|
||||
<office:master-styles>
|
||||
<style:master-page style:name="Standard" style:page-layout-name="pm1"/>
|
||||
</office:master-styles>
|
||||
</office:document-styles>`;
|
||||
|
||||
/**
|
||||
* Write a minimal .odt fixture containing just styles.xml.
|
||||
* @param {string} filePath destination
|
||||
* @param {string} [stylesXml] styles.xml content (defaults to STYLES_XML)
|
||||
*/
|
||||
function writeOdtFixture(filePath, stylesXml = STYLES_XML) {
|
||||
const zip = new PizZip();
|
||||
zip.file('mimetype', 'application/vnd.oasis.opendocument.text');
|
||||
zip.file('styles.xml', stylesXml);
|
||||
zip.file('content.xml', '<office:document-content/>');
|
||||
fs.writeFileSync(filePath, zip.generate({ type: 'nodebuffer' }));
|
||||
}
|
||||
|
||||
/** Read styles.xml back out of a written fixture. */
|
||||
function readStylesXml(filePath) {
|
||||
return new PizZip(fs.readFileSync(filePath)).file('styles.xml').asText();
|
||||
}
|
||||
|
||||
describe('OdtStyling', () => {
|
||||
let tmpDir, odtPath;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'odtstyle_'));
|
||||
odtPath = path.join(tmpDir, 'doc.odt');
|
||||
writeOdtFixture(odtPath);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('parseDimensionsMm', () => {
|
||||
it('parses "210×297mm" (U+00D7 separator) into numbers', () => {
|
||||
expect(parseDimensionsMm('210×297mm')).toEqual({ widthMm: 210, heightMm: 297 });
|
||||
});
|
||||
|
||||
it('returns null for unparseable input', () => {
|
||||
expect(parseDimensionsMm('8.5in')).toBeNull();
|
||||
expect(parseDimensionsMm(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeOdtText', () => {
|
||||
it('escapes XML-significant characters', () => {
|
||||
expect(escapeOdtText('a<b>&"c"')).toBe('a<b>&"c"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setOdtPageSize', () => {
|
||||
it('rewrites width/height/orientation on the existing page layout (A4 portrait)', async () => {
|
||||
await setOdtPageSize(odtPath, {
|
||||
size: 'a4',
|
||||
orientation: 'portrait',
|
||||
pageSizes: { a4: { dimensions: '210×297mm' } },
|
||||
});
|
||||
|
||||
const xml = readStylesXml(odtPath);
|
||||
expect(xml).toContain('fo:page-width="210mm"');
|
||||
expect(xml).toContain('fo:page-height="297mm"');
|
||||
expect(xml).toContain('style:print-orientation="portrait"');
|
||||
// Old inch-based attributes must be gone (no duplicate size attrs)
|
||||
expect(xml).not.toContain('fo:page-width="8.267in"');
|
||||
});
|
||||
|
||||
it('swaps dimensions for landscape', async () => {
|
||||
await setOdtPageSize(odtPath, {
|
||||
size: 'a4',
|
||||
orientation: 'landscape',
|
||||
pageSizes: { a4: { dimensions: '210×297mm' } },
|
||||
});
|
||||
|
||||
const xml = readStylesXml(odtPath);
|
||||
expect(xml).toContain('fo:page-width="297mm"');
|
||||
expect(xml).toContain('fo:page-height="210mm"');
|
||||
expect(xml).toContain('style:print-orientation="landscape"');
|
||||
});
|
||||
|
||||
it('falls back to custom mm dimensions and A4 default', async () => {
|
||||
await setOdtPageSize(odtPath, { customWidth: '148', customHeight: '210' });
|
||||
expect(readStylesXml(odtPath)).toContain('fo:page-width="148mm"');
|
||||
|
||||
writeOdtFixture(odtPath);
|
||||
await setOdtPageSize(odtPath, {});
|
||||
expect(readStylesXml(odtPath)).toContain('fo:page-width="210mm"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addHeaderFooterToOdt', () => {
|
||||
it('is a no-op when disabled', async () => {
|
||||
await addHeaderFooterToOdt(odtPath, { enabled: false, header: { left: 'X' } });
|
||||
expect(readStylesXml(odtPath)).not.toContain('<style:header');
|
||||
});
|
||||
|
||||
it('injects header and footer regions into the Standard master page', async () => {
|
||||
await addHeaderFooterToOdt(odtPath, {
|
||||
enabled: true,
|
||||
header: { left: 'My Doc', right: 'Draft <v2>' },
|
||||
footer: { center: 'Page $PAGE$ of $TOTAL$' },
|
||||
});
|
||||
|
||||
const xml = readStylesXml(odtPath);
|
||||
expect(xml).toContain('<style:header>');
|
||||
expect(xml).toContain('<style:region-left');
|
||||
expect(xml).toContain('My Doc');
|
||||
// User text must be XML-escaped
|
||||
expect(xml).toContain('Draft <v2>');
|
||||
expect(xml).toContain('<text:page-number>');
|
||||
expect(xml).toContain('<text:page-count>');
|
||||
// The self-closing master page must have been expanded to hold children
|
||||
expect(xml).toMatch(/<style:master-page[^>]*>[\s\S]*<\/style:master-page>/);
|
||||
});
|
||||
|
||||
it('does nothing when all regions are empty', async () => {
|
||||
await addHeaderFooterToOdt(odtPath, {
|
||||
enabled: true,
|
||||
header: { left: '', center: '', right: '' },
|
||||
footer: {},
|
||||
});
|
||||
expect(readStylesXml(odtPath)).toBe(STYLES_XML);
|
||||
});
|
||||
|
||||
it('creates a master page when the package has none', async () => {
|
||||
const bareXml = STYLES_XML.replace(
|
||||
/ <office:master-styles>[\s\S]*<\/office:master-styles>/,
|
||||
''
|
||||
);
|
||||
writeOdtFixture(odtPath, bareXml);
|
||||
|
||||
await addHeaderFooterToOdt(odtPath, {
|
||||
enabled: true,
|
||||
footer: { center: 'hi' },
|
||||
});
|
||||
|
||||
const xml = readStylesXml(odtPath);
|
||||
expect(xml).toContain('<office:master-styles>');
|
||||
expect(xml).toContain('style:name="Standard"');
|
||||
expect(xml).toContain('hi');
|
||||
});
|
||||
|
||||
it('replaces existing header/footer content on re-export', async () => {
|
||||
await addHeaderFooterToOdt(odtPath, { enabled: true, header: { left: 'first' } });
|
||||
await addHeaderFooterToOdt(odtPath, { enabled: true, header: { left: 'second' } });
|
||||
|
||||
const xml = readStylesXml(odtPath);
|
||||
expect(xml).toContain('second');
|
||||
expect(xml).not.toContain('first');
|
||||
// Exactly one header element, not nested duplicates
|
||||
expect(xml.match(/<style:header>/g)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user