Release v3.0.0: PDF Viewer & Editor Integration

Major Features:
- Integrated PDF viewer with zoom, page navigation, and fit modes
- PDF editor toolbar with direct access to all PDF operations
- New app branding with docico1.png icon
- ConcreteInfo theme (light & dark variants)
- Comprehensive PDF editing: merge, split, compress, rotate, delete, reorder
- Watermark, encrypt, decrypt, and permissions management
- Overwrite original file option for all PDF operations

UI Improvements:
- PDF viewer takes full screen when active (hides tabs and toolbar)
- Compact modals with reduced font sizes
- Dark theme fixes for icons and dialogs
- Save/Save As options for PDF operations
- Improved PDF toolbar theming

Technical:
- Added isPdfViewerActive state tracking
- Proper PDF document lifecycle management
- Fixed hang issues when switching between PDF and markdown
- All dependencies are open-source (MIT, Apache 2.0, BSD)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-06 18:28:55 +05:30
co-authored by Claude
parent 3a3f0b9ac1
commit b96a6e5bd2
39 changed files with 6245 additions and 454 deletions
+121
View File
@@ -0,0 +1,121 @@
/**
* Tests for Preload Script Security
* Verifies that the IPC bridge is properly configured
*/
describe('Preload Security', () => {
describe('Allowed Channels', () => {
const EXPECTED_SEND_CHANNELS = [
'save-file',
'save-current-file',
'set-current-file',
'save-recent-files',
'clear-recent-files',
'renderer-ready',
'get-theme',
'do-print',
'export-with-options',
'export-spreadsheet',
'batch-convert',
'select-folder',
'universal-convert',
'universal-convert-batch',
'get-header-footer-settings',
'save-header-footer-settings',
'browse-header-footer-logo',
'save-header-footer-logo',
'clear-header-footer-logo',
'get-page-settings',
'update-page-settings',
'set-custom-start-page',
'process-pdf-operation',
'get-pdf-page-count',
'select-pdf-folder'
];
const EXPECTED_RECEIVE_CHANNELS = [
'file-new',
'file-opened',
'file-save',
'get-content-for-save',
'get-content-for-spreadsheet',
'recent-files-cleared',
'toggle-preview',
'toggle-find',
'theme-changed',
'theme-data',
'undo',
'redo',
'adjust-font-size',
'print-preview',
'print-preview-styled',
'show-export-dialog',
'show-batch-dialog',
'show-universal-converter-dialog',
'show-table-generator',
'show-pdf-editor-dialog',
'conversion-status',
'conversion-complete',
'batch-progress',
'folder-selected',
'pdf-folder-selected',
'header-footer-settings-data',
'header-footer-logo-selected',
'header-footer-logo-saved',
'page-settings-data',
'pdf-page-count',
'pdf-operation-complete',
'pdf-operation-error'
];
test('should define all expected send channels', () => {
// This test documents expected channels
expect(EXPECTED_SEND_CHANNELS.length).toBeGreaterThan(0);
});
test('should define all expected receive channels', () => {
// This test documents expected channels
expect(EXPECTED_RECEIVE_CHANNELS.length).toBeGreaterThan(0);
});
});
describe('electronAPI Interface', () => {
test('should expose send method', () => {
expect(window.electronAPI.send).toBeDefined();
expect(typeof window.electronAPI.send).toBe('function');
});
test('should expose on method', () => {
expect(window.electronAPI.on).toBeDefined();
expect(typeof window.electronAPI.on).toBe('function');
});
test('should expose once method', () => {
expect(window.electronAPI.once).toBeDefined();
expect(typeof window.electronAPI.once).toBe('function');
});
test('should expose invoke method', () => {
expect(window.electronAPI.invoke).toBeDefined();
expect(typeof window.electronAPI.invoke).toBe('function');
});
test('should expose file convenience methods', () => {
expect(window.electronAPI.file).toBeDefined();
expect(window.electronAPI.file.save).toBeDefined();
expect(window.electronAPI.file.saveCurrent).toBeDefined();
expect(window.electronAPI.file.setCurrent).toBeDefined();
});
test('should expose theme convenience methods', () => {
expect(window.electronAPI.theme).toBeDefined();
expect(window.electronAPI.theme.get).toBeDefined();
});
test('should expose pdf convenience methods', () => {
expect(window.electronAPI.pdf).toBeDefined();
expect(window.electronAPI.pdf.processOperation).toBeDefined();
expect(window.electronAPI.pdf.getPageCount).toBeDefined();
});
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* Jest Test Setup
* Provides mocks and utilities for PanConverter tests
*/
// Mock window.electronAPI for renderer tests
global.window = global.window || {};
global.window.electronAPI = {
send: jest.fn(),
on: jest.fn(() => jest.fn()), // Returns cleanup function
once: jest.fn(),
invoke: jest.fn(() => Promise.resolve(null)),
removeAllListeners: jest.fn(),
file: {
save: jest.fn(),
saveCurrent: jest.fn(),
setCurrent: jest.fn(),
saveRecent: jest.fn(),
clearRecent: jest.fn(),
rendererReady: jest.fn()
},
theme: {
get: jest.fn()
},
print: {
doPrint: jest.fn()
},
export: {
withOptions: jest.fn(),
spreadsheet: jest.fn()
},
batch: {
convert: jest.fn(),
selectFolder: jest.fn()
},
converter: {
convert: jest.fn(),
convertBatch: jest.fn()
},
headerFooter: {
getSettings: jest.fn(),
saveSettings: jest.fn(),
browseLogo: jest.fn(),
saveLogo: jest.fn(),
clearLogo: jest.fn()
},
page: {
getSettings: jest.fn(),
updateSettings: jest.fn(),
setCustomStartPage: jest.fn()
},
pdf: {
processOperation: jest.fn(),
getPageCount: jest.fn(),
selectFolder: jest.fn()
}
};
// Mock marked library
global.window.marked = {
parse: jest.fn((text) => `<p>${text}</p>`),
setOptions: jest.fn()
};
// Mock DOMPurify
global.window.DOMPurify = {
sanitize: jest.fn((html) => html)
};
// Mock highlight.js
global.window.hljs = {
highlight: jest.fn((code, options) => ({ value: code })),
highlightAuto: jest.fn((code) => ({ value: code })),
getLanguage: jest.fn(() => true)
};
// Mock localStorage
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn()
};
global.localStorage = localStorageMock;
// Console spy to catch unintended console logs in tests
const originalConsoleError = console.error;
console.error = (...args) => {
// Allow certain expected errors
const message = args[0]?.toString() || '';
if (message.includes('Warning:') || message.includes('React')) {
return;
}
originalConsoleError.apply(console, args);
};
// Cleanup after each test
afterEach(() => {
jest.clearAllMocks();
});
+137
View File
@@ -0,0 +1,137 @@
/**
* Tests for Utility Functions
* Tests helper functions that can be extracted and tested
*/
describe('Utility Functions', () => {
describe('parseCommand', () => {
// This function parses command strings into command and args
function parseCommand(cmdString) {
const parts = [];
let current = '';
let inQuotes = false;
let quoteChar = '';
for (let i = 0; i < cmdString.length; i++) {
const char = cmdString[i];
if ((char === '"' || char === "'") && !inQuotes) {
inQuotes = true;
quoteChar = char;
} else if (char === quoteChar && inQuotes) {
inQuotes = false;
quoteChar = '';
} else if (char === ' ' && !inQuotes) {
if (current) {
parts.push(current);
current = '';
}
} else {
current += char;
}
}
if (current) {
parts.push(current);
}
return {
command: parts[0],
args: parts.slice(1)
};
}
test('should parse simple command', () => {
const result = parseCommand('pandoc input.md -o output.pdf');
expect(result.command).toBe('pandoc');
expect(result.args).toEqual(['input.md', '-o', 'output.pdf']);
});
test('should handle double-quoted paths', () => {
const result = parseCommand('pandoc "C:/path with spaces/file.md" -o output.pdf');
expect(result.command).toBe('pandoc');
expect(result.args).toEqual(['C:/path with spaces/file.md', '-o', 'output.pdf']);
});
test('should handle single-quoted paths', () => {
const result = parseCommand("pandoc 'file name.md' -o output.pdf");
expect(result.command).toBe('pandoc');
expect(result.args).toEqual(['file name.md', '-o', 'output.pdf']);
});
test('should handle multiple options', () => {
const result = parseCommand('pandoc input.md --pdf-engine=xelatex -V geometry:margin=1in -o output.pdf');
expect(result.command).toBe('pandoc');
expect(result.args).toContain('--pdf-engine=xelatex');
expect(result.args).toContain('-V');
});
test('should handle empty command', () => {
const result = parseCommand('');
expect(result.command).toBeUndefined();
expect(result.args).toEqual([]);
});
});
describe('hexToRgb', () => {
// This function converts hex colors to RGB
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16) / 255,
g: parseInt(result[2], 16) / 255,
b: parseInt(result[3], 16) / 255
} : null;
}
test('should convert black hex to RGB', () => {
const result = hexToRgb('#000000');
expect(result).toEqual({ r: 0, g: 0, b: 0 });
});
test('should convert white hex to RGB', () => {
const result = hexToRgb('#ffffff');
expect(result).toEqual({ r: 1, g: 1, b: 1 });
});
test('should convert red hex to RGB', () => {
const result = hexToRgb('#ff0000');
expect(result.r).toBeCloseTo(1);
expect(result.g).toBeCloseTo(0);
expect(result.b).toBeCloseTo(0);
});
test('should handle hex without hash', () => {
const result = hexToRgb('00ff00');
expect(result.r).toBeCloseTo(0);
expect(result.g).toBeCloseTo(1);
expect(result.b).toBeCloseTo(0);
});
test('should return null for invalid hex', () => {
expect(hexToRgb('invalid')).toBeNull();
expect(hexToRgb('#xyz')).toBeNull();
});
});
describe('File Path Utilities', () => {
test('should extract file extension', () => {
const getExtension = (filepath) => {
const match = filepath.match(/\.([^/.]+)$/);
return match ? match[1].toLowerCase() : '';
};
expect(getExtension('file.md')).toBe('md');
expect(getExtension('document.PDF')).toBe('pdf');
expect(getExtension('path/to/file.docx')).toBe('docx');
expect(getExtension('noextension')).toBe('');
});
test('should replace file extension', () => {
const replaceExtension = (filepath, newExt) => {
return filepath.replace(/\.[^/.]+$/, `.${newExt}`);
};
expect(replaceExtension('file.md', 'pdf')).toBe('file.pdf');
expect(replaceExtension('path/to/doc.docx', 'html')).toBe('path/to/doc.html');
});
});
});