diff --git a/src/js/config/tools.ts b/src/js/config/tools.ts index 862b2ca..0772dd5 100644 --- a/src/js/config/tools.ts +++ b/src/js/config/tools.ts @@ -1,5 +1,5 @@ // This file centralizes the definition of all available tools, organized by category. -export const categories = [ +const baseCategories = [ { name: 'Popular Tools', tools: [ @@ -788,3 +788,16 @@ export const categories = [ ], }, ]; + +const getToolIdFromHref = (href: string): string => { + const match = href.match(/\/([^/]+)\.html$/); + return match?.[1] ?? href; +}; + +export const categories = baseCategories.map((category) => ({ + ...category, + tools: category.tools.map((tool) => ({ + ...tool, + id: getToolIdFromHref(tool.href), + })), +})); diff --git a/src/js/utils/pdf-operations.ts b/src/js/utils/pdf-operations.ts index a4fd290..67b5f06 100644 --- a/src/js/utils/pdf-operations.ts +++ b/src/js/utils/pdf-operations.ts @@ -12,7 +12,7 @@ export async function mergePdfs( ); copiedPages.forEach((page) => mergedDoc.addPage(page)); } - return new Uint8Array(await mergedDoc.save()); + return new Uint8Array(await mergedDoc.save({ addDefaultPage: false })); } export async function splitPdf( diff --git a/src/tests/hocr-transform.test.ts b/src/tests/hocr-transform.test.ts new file mode 100644 index 0000000..844ce0a --- /dev/null +++ b/src/tests/hocr-transform.test.ts @@ -0,0 +1,440 @@ +import { describe, it, expect } from 'vitest'; +import { + parseBBox, + parseBaseline, + parseTextangle, + getTextDirection, + shouldInjectWordBreaks, + normalizeText, + calculateWordTransform, + calculateSpaceTransform, +} from '../js/utils/hocr-transform'; + +describe('hocr-transform', () => { + describe('parseBBox', () => { + it('should parse valid bbox string', () => { + expect(parseBBox('bbox 100 200 300 400')).toEqual({ + x0: 100, + y0: 200, + x1: 300, + y1: 400, + }); + }); + + it('should parse bbox with other attributes', () => { + expect(parseBBox('bbox 10 20 30 40; x_wconf 95')).toEqual({ + x0: 10, + y0: 20, + x1: 30, + y1: 40, + }); + }); + + it('should return null for missing bbox', () => { + expect(parseBBox('x_wconf 95')).toBeNull(); + }); + + it('should return null for empty string', () => { + expect(parseBBox('')).toBeNull(); + }); + + it('should parse zero values', () => { + expect(parseBBox('bbox 0 0 0 0')).toEqual({ + x0: 0, + y0: 0, + x1: 0, + y1: 0, + }); + }); + + it('should parse large coordinates', () => { + expect(parseBBox('bbox 0 0 2480 3508')).toEqual({ + x0: 0, + y0: 0, + x1: 2480, + y1: 3508, + }); + }); + }); + + describe('parseBaseline', () => { + it('should parse valid baseline', () => { + expect(parseBaseline('baseline 0.012 -5')).toEqual({ + slope: 0.012, + intercept: -5, + }); + }); + + it('should parse zero baseline', () => { + expect(parseBaseline('baseline 0 0')).toEqual({ + slope: 0, + intercept: 0, + }); + }); + + it('should parse negative slope', () => { + expect(parseBaseline('baseline -0.005 3')).toEqual({ + slope: -0.005, + intercept: 3, + }); + }); + + it('should return defaults for missing baseline', () => { + expect(parseBaseline('bbox 100 200 300 400')).toEqual({ + slope: 0, + intercept: 0, + }); + }); + + it('should return defaults for empty string', () => { + expect(parseBaseline('')).toEqual({ + slope: 0, + intercept: 0, + }); + }); + + it('should handle baseline with other attributes', () => { + expect(parseBaseline('bbox 10 20 30 40; baseline 0.1 -2')).toEqual({ + slope: 0.1, + intercept: -2, + }); + }); + }); + + describe('parseTextangle', () => { + it('should parse valid textangle', () => { + expect(parseTextangle('textangle 90')).toBe(90); + }); + + it('should parse float textangle', () => { + expect(parseTextangle('textangle 1.5')).toBe(1.5); + }); + + it('should parse negative textangle', () => { + expect(parseTextangle('textangle -45')).toBe(-45); + }); + + it('should return 0 for missing textangle', () => { + expect(parseTextangle('bbox 0 0 100 100')).toBe(0); + }); + + it('should return 0 for empty string', () => { + expect(parseTextangle('')).toBe(0); + }); + + it('should parse zero textangle', () => { + expect(parseTextangle('textangle 0')).toBe(0); + }); + }); + + describe('getTextDirection', () => { + it('should return rtl for dir="rtl" attribute', () => { + const el = document.createElement('div'); + el.setAttribute('dir', 'rtl'); + expect(getTextDirection(el)).toBe('rtl'); + }); + + it('should return ltr for dir="ltr" attribute', () => { + const el = document.createElement('div'); + el.setAttribute('dir', 'ltr'); + expect(getTextDirection(el)).toBe('ltr'); + }); + + it('should default to ltr when no dir attribute', () => { + const el = document.createElement('div'); + expect(getTextDirection(el)).toBe('ltr'); + }); + + it('should default to ltr for unknown dir values', () => { + const el = document.createElement('div'); + el.setAttribute('dir', 'auto'); + expect(getTextDirection(el)).toBe('ltr'); + }); + }); + + describe('shouldInjectWordBreaks', () => { + it('should return true for English', () => { + const el = document.createElement('div'); + el.setAttribute('lang', 'eng'); + expect(shouldInjectWordBreaks(el)).toBe(true); + }); + + it('should return true for no lang attribute', () => { + const el = document.createElement('div'); + expect(shouldInjectWordBreaks(el)).toBe(true); + }); + + it('should return false for CJK languages', () => { + const cjkLangs = ['chi_sim', 'chi_tra', 'jpn', 'kor', 'zh', 'ja', 'ko']; + cjkLangs.forEach((lang) => { + const el = document.createElement('div'); + el.setAttribute('lang', lang); + expect(shouldInjectWordBreaks(el)).toBe(false); + }); + }); + + it('should return true for non-CJK languages', () => { + const langs = ['fra', 'deu', 'spa', 'ara', 'hin']; + langs.forEach((lang) => { + const el = document.createElement('div'); + el.setAttribute('lang', lang); + expect(shouldInjectWordBreaks(el)).toBe(true); + }); + }); + }); + + describe('normalizeText', () => { + it('should normalize NFKC', () => { + expect(normalizeText('\ufb01')).toBe('fi'); + }); + + it('should keep ASCII text unchanged', () => { + expect(normalizeText('hello')).toBe('hello'); + }); + + it('should handle empty string', () => { + expect(normalizeText('')).toBe(''); + }); + + it('should normalize full-width characters', () => { + expect(normalizeText('\uff21')).toBe('A'); + }); + + it('should normalize superscript digits', () => { + expect(normalizeText('\u00b2')).toBe('2'); + }); + }); + + describe('calculateWordTransform', () => { + const baseLine = { + bbox: { x0: 0, y0: 100, x1: 500, y1: 130 }, + baseline: { slope: 0, intercept: 0 }, + textangle: 0, + words: [], + direction: 'ltr' as const, + injectWordBreaks: true, + }; + + it('should calculate position and font size', () => { + const word = { + text: 'Hello', + bbox: { x0: 10, y0: 100, x1: 60, y1: 120 }, + confidence: 95, + }; + const fontWidthFn = (_text: string, fontSize: number) => fontSize * 2.5; + + const result = calculateWordTransform(word, baseLine, 800, fontWidthFn); + + expect(result.x).toBe(10); + expect(result.y).toBe(680); + expect(result.fontSize).toBeGreaterThan(0); + expect(result.horizontalScale).toBeGreaterThan(0); + }); + + it('should clamp font size to max 2x word height', () => { + const word = { + text: 'x', + bbox: { x0: 0, y0: 0, x1: 1000, y1: 10 }, + confidence: 95, + }; + const fontWidthFn = (_text: string, _fontSize: number) => 0.001; + + const result = calculateWordTransform(word, baseLine, 800, fontWidthFn); + expect(result.fontSize).toBeLessThanOrEqual(20); + }); + + it('should clamp font size to minimum 1', () => { + const word = { + text: 'x', + bbox: { x0: 0, y0: 0, x1: 1, y1: 10 }, + confidence: 95, + }; + const fontWidthFn = (_text: string, _fontSize: number) => 10000; + + const result = calculateWordTransform(word, baseLine, 800, fontWidthFn); + expect(result.fontSize).toBeGreaterThanOrEqual(1); + }); + + it('should handle zero font width gracefully', () => { + const word = { + text: '', + bbox: { x0: 0, y0: 0, x1: 50, y1: 20 }, + confidence: 95, + }; + const fontWidthFn = () => 0; + + const result = calculateWordTransform(word, baseLine, 800, fontWidthFn); + expect(result.horizontalScale).toBe(1); + }); + + it('should incorporate baseline slope into rotation', () => { + const slopedLine = { + ...baseLine, + baseline: { slope: 0.1, intercept: 0 }, + }; + const word = { + text: 'Hi', + bbox: { x0: 10, y0: 100, x1: 40, y1: 115 }, + confidence: 90, + }; + const fontWidthFn = (_text: string, fontSize: number) => fontSize * 1.5; + + const result = calculateWordTransform(word, slopedLine, 800, fontWidthFn); + expect(result.rotation).not.toBe(0); + }); + + it('should incorporate textangle into rotation', () => { + const angledLine = { ...baseLine, textangle: 5 }; + const word = { + text: 'Hi', + bbox: { x0: 10, y0: 100, x1: 40, y1: 115 }, + confidence: 90, + }; + const fontWidthFn = (_text: string, fontSize: number) => fontSize * 1.5; + + const result = calculateWordTransform(word, angledLine, 800, fontWidthFn); + expect(result.rotation).toBe(-5); + }); + }); + + describe('calculateSpaceTransform', () => { + const baseLine = { + bbox: { x0: 0, y0: 100, x1: 500, y1: 130 }, + baseline: { slope: 0, intercept: 0 }, + textangle: 0, + words: [], + direction: 'ltr' as const, + injectWordBreaks: true, + }; + + it('should calculate space between two words', () => { + const prev = { + text: 'Hello', + bbox: { x0: 10, y0: 100, x1: 60, y1: 120 }, + confidence: 95, + }; + const next = { + text: 'World', + bbox: { x0: 70, y0: 100, x1: 130, y1: 120 }, + confidence: 95, + }; + const spaceWidthFn = (fontSize: number) => fontSize * 0.3; + + const result = calculateSpaceTransform( + prev, + next, + baseLine, + 800, + spaceWidthFn + ); + expect(result).not.toBeNull(); + expect(result!.x).toBe(60); + expect(result!.horizontalScale).toBeGreaterThan(0); + }); + + it('should return null when gap is zero or negative', () => { + const prev = { + text: 'Hello', + bbox: { x0: 10, y0: 100, x1: 60, y1: 120 }, + confidence: 95, + }; + const next = { + text: 'World', + bbox: { x0: 60, y0: 100, x1: 120, y1: 120 }, + confidence: 95, + }; + const spaceWidthFn = (fontSize: number) => fontSize * 0.3; + + expect( + calculateSpaceTransform(prev, next, baseLine, 800, spaceWidthFn) + ).toBeNull(); + }); + + it('should return null when overlapping words', () => { + const prev = { + text: 'Hello', + bbox: { x0: 10, y0: 100, x1: 80, y1: 120 }, + confidence: 95, + }; + const next = { + text: 'World', + bbox: { x0: 70, y0: 100, x1: 130, y1: 120 }, + confidence: 95, + }; + const spaceWidthFn = (fontSize: number) => fontSize * 0.3; + + expect( + calculateSpaceTransform(prev, next, baseLine, 800, spaceWidthFn) + ).toBeNull(); + }); + + it('should return null when space width function returns 0', () => { + const prev = { + text: 'Hello', + bbox: { x0: 10, y0: 100, x1: 60, y1: 120 }, + confidence: 95, + }; + const next = { + text: 'World', + bbox: { x0: 70, y0: 100, x1: 130, y1: 120 }, + confidence: 95, + }; + + expect( + calculateSpaceTransform(prev, next, baseLine, 800, () => 0) + ).toBeNull(); + }); + + it('should account for baseline intercept in y position', () => { + const lineWithIntercept = { + ...baseLine, + baseline: { slope: 0, intercept: -5 }, + }; + const prev = { + text: 'A', + bbox: { x0: 0, y0: 100, x1: 20, y1: 130 }, + confidence: 90, + }; + const next = { + text: 'B', + bbox: { x0: 30, y0: 100, x1: 50, y1: 130 }, + confidence: 90, + }; + const spaceWidthFn = (fontSize: number) => fontSize * 0.3; + + const result = calculateSpaceTransform( + prev, + next, + lineWithIntercept, + 800, + spaceWidthFn + ); + expect(result).not.toBeNull(); + expect(result!.y).toBe(800 - 130 - -5); + }); + + it('should use line height + intercept for font size', () => { + const prev = { + text: 'A', + bbox: { x0: 0, y0: 100, x1: 20, y1: 130 }, + confidence: 90, + }; + const next = { + text: 'B', + bbox: { x0: 30, y0: 100, x1: 50, y1: 130 }, + confidence: 90, + }; + const spaceWidthFn = (fontSize: number) => fontSize * 0.3; + + const result = calculateSpaceTransform( + prev, + next, + baseLine, + 800, + spaceWidthFn + ); + expect(result).not.toBeNull(); + expect(result!.fontSize).toBe(30); + }); + }); +}); diff --git a/src/tests/image-effects.test.ts b/src/tests/image-effects.test.ts new file mode 100644 index 0000000..5850335 --- /dev/null +++ b/src/tests/image-effects.test.ts @@ -0,0 +1,277 @@ +import { describe, it, expect } from 'vitest'; +import { + rgbToHsl, + hslToRgb, + applyGreyscale, + applyInvertColors, +} from '../js/utils/image-effects'; + +function createImageData(pixels: number[][]): ImageData { + const data = new Uint8ClampedArray(pixels.flat()); + return { + data, + width: pixels.length, + height: 1, + colorSpace: 'srgb', + } as ImageData; +} + +describe('image-effects', () => { + describe('rgbToHsl', () => { + it('should convert pure red', () => { + const [h, s, l] = rgbToHsl(255, 0, 0); + expect(h).toBeCloseTo(0, 2); + expect(s).toBeCloseTo(1, 2); + expect(l).toBeCloseTo(0.5, 2); + }); + + it('should convert pure green', () => { + const [h, s, l] = rgbToHsl(0, 255, 0); + expect(h).toBeCloseTo(1 / 3, 2); + expect(s).toBeCloseTo(1, 2); + expect(l).toBeCloseTo(0.5, 2); + }); + + it('should convert pure blue', () => { + const [h, s, l] = rgbToHsl(0, 0, 255); + expect(h).toBeCloseTo(2 / 3, 2); + expect(s).toBeCloseTo(1, 2); + expect(l).toBeCloseTo(0.5, 2); + }); + + it('should convert white', () => { + const [h, s, l] = rgbToHsl(255, 255, 255); + expect(h).toBe(0); + expect(s).toBe(0); + expect(l).toBeCloseTo(1, 2); + }); + + it('should convert black', () => { + const [h, s, l] = rgbToHsl(0, 0, 0); + expect(h).toBe(0); + expect(s).toBe(0); + expect(l).toBe(0); + }); + + it('should convert mid gray', () => { + const [h, s, l] = rgbToHsl(128, 128, 128); + expect(h).toBe(0); + expect(s).toBe(0); + expect(l).toBeCloseTo(0.502, 2); + }); + + it('should convert yellow', () => { + const [h, s, l] = rgbToHsl(255, 255, 0); + expect(h).toBeCloseTo(1 / 6, 2); + expect(s).toBeCloseTo(1, 2); + expect(l).toBeCloseTo(0.5, 2); + }); + + it('should convert cyan', () => { + const [h, s, l] = rgbToHsl(0, 255, 255); + expect(h).toBeCloseTo(0.5, 2); + expect(s).toBeCloseTo(1, 2); + expect(l).toBeCloseTo(0.5, 2); + }); + + it('should convert magenta', () => { + const [h, s, l] = rgbToHsl(255, 0, 255); + expect(h).toBeCloseTo(5 / 6, 2); + expect(s).toBeCloseTo(1, 2); + expect(l).toBeCloseTo(0.5, 2); + }); + + it('should handle dark colors (l < 0.5)', () => { + const [h, s, l] = rgbToHsl(128, 0, 0); + expect(h).toBeCloseTo(0, 2); + expect(s).toBeCloseTo(1, 2); + expect(l).toBeCloseTo(0.251, 2); + }); + + it('should handle light colors (l > 0.5)', () => { + const [h, s, l] = rgbToHsl(255, 128, 128); + expect(l).toBeGreaterThan(0.5); + expect(s).toBeGreaterThan(0); + }); + }); + + describe('hslToRgb', () => { + it('should convert pure red', () => { + expect(hslToRgb(0, 1, 0.5)).toEqual([255, 0, 0]); + }); + + it('should convert pure green', () => { + expect(hslToRgb(1 / 3, 1, 0.5)).toEqual([0, 255, 0]); + }); + + it('should convert pure blue', () => { + expect(hslToRgb(2 / 3, 1, 0.5)).toEqual([0, 0, 255]); + }); + + it('should convert white', () => { + expect(hslToRgb(0, 0, 1)).toEqual([255, 255, 255]); + }); + + it('should convert black', () => { + expect(hslToRgb(0, 0, 0)).toEqual([0, 0, 0]); + }); + + it('should convert gray (zero saturation)', () => { + const [r, g, b] = hslToRgb(0, 0, 0.5); + expect(r).toBe(g); + expect(g).toBe(b); + expect(r).toBe(128); + }); + + it('should convert yellow', () => { + expect(hslToRgb(1 / 6, 1, 0.5)).toEqual([255, 255, 0]); + }); + + it('should convert cyan', () => { + expect(hslToRgb(0.5, 1, 0.5)).toEqual([0, 255, 255]); + }); + + it('should handle different saturation values', () => { + const [r1] = hslToRgb(0, 0.5, 0.5); + const [r2] = hslToRgb(0, 1, 0.5); + expect(r2).toBeGreaterThan(r1); + }); + + it('should handle l < 0.5 branch', () => { + const result = hslToRgb(0, 1, 0.25); + expect(result[0]).toBe(128); + expect(result[1]).toBe(0); + expect(result[2]).toBe(0); + }); + + it('should handle l >= 0.5 branch', () => { + const result = hslToRgb(0, 1, 0.75); + expect(result[0]).toBe(255); + expect(result[1]).toBe(128); + expect(result[2]).toBe(128); + }); + }); + + describe('rgbToHsl <-> hslToRgb round-trip', () => { + const testColors: [number, number, number][] = [ + [255, 0, 0], + [0, 255, 0], + [0, 0, 255], + [255, 255, 0], + [0, 255, 255], + [255, 0, 255], + [128, 128, 128], + [200, 100, 50], + [50, 150, 200], + [10, 10, 10], + [245, 245, 245], + ]; + + testColors.forEach(([r, g, b]) => { + it(`should round-trip rgb(${r}, ${g}, ${b})`, () => { + const [h, s, l] = rgbToHsl(r, g, b); + const [r2, g2, b2] = hslToRgb(h, s, l); + expect(r2).toBeCloseTo(r, 0); + expect(g2).toBeCloseTo(g, 0); + expect(b2).toBeCloseTo(b, 0); + }); + }); + }); + + describe('applyGreyscale', () => { + it('should convert colored pixel to grey using luminance weights', () => { + const imageData = createImageData([[255, 0, 0, 255]]); + applyGreyscale(imageData); + const expected = Math.round(0.299 * 255); + expect(imageData.data[0]).toBe(expected); + expect(imageData.data[1]).toBe(expected); + expect(imageData.data[2]).toBe(expected); + expect(imageData.data[3]).toBe(255); + }); + + it('should keep white as white', () => { + const imageData = createImageData([[255, 255, 255, 255]]); + applyGreyscale(imageData); + expect(imageData.data[0]).toBe(255); + expect(imageData.data[1]).toBe(255); + expect(imageData.data[2]).toBe(255); + }); + + it('should keep black as black', () => { + const imageData = createImageData([[0, 0, 0, 255]]); + applyGreyscale(imageData); + expect(imageData.data[0]).toBe(0); + expect(imageData.data[1]).toBe(0); + expect(imageData.data[2]).toBe(0); + }); + + it('should process multiple pixels', () => { + const imageData = createImageData([ + [255, 0, 0, 255], + [0, 255, 0, 255], + [0, 0, 255, 255], + ]); + applyGreyscale(imageData); + expect(imageData.data[0]).toBe(imageData.data[1]); + expect(imageData.data[4]).toBe(imageData.data[5]); + expect(imageData.data[8]).toBe(imageData.data[9]); + }); + + it('should not modify alpha channel', () => { + const imageData = createImageData([[100, 150, 200, 128]]); + applyGreyscale(imageData); + expect(imageData.data[3]).toBe(128); + }); + }); + + describe('applyInvertColors', () => { + it('should invert black to white', () => { + const imageData = createImageData([[0, 0, 0, 255]]); + applyInvertColors(imageData); + expect(imageData.data[0]).toBe(255); + expect(imageData.data[1]).toBe(255); + expect(imageData.data[2]).toBe(255); + }); + + it('should invert white to black', () => { + const imageData = createImageData([[255, 255, 255, 255]]); + applyInvertColors(imageData); + expect(imageData.data[0]).toBe(0); + expect(imageData.data[1]).toBe(0); + expect(imageData.data[2]).toBe(0); + }); + + it('should invert mid-range colors', () => { + const imageData = createImageData([[100, 150, 200, 255]]); + applyInvertColors(imageData); + expect(imageData.data[0]).toBe(155); + expect(imageData.data[1]).toBe(105); + expect(imageData.data[2]).toBe(55); + }); + + it('should not modify alpha channel', () => { + const imageData = createImageData([[100, 150, 200, 128]]); + applyInvertColors(imageData); + expect(imageData.data[3]).toBe(128); + }); + + it('should be its own inverse (double invert = original)', () => { + const imageData = createImageData([[42, 128, 200, 255]]); + applyInvertColors(imageData); + applyInvertColors(imageData); + expect(imageData.data[0]).toBe(42); + expect(imageData.data[1]).toBe(128); + expect(imageData.data[2]).toBe(200); + }); + + it('should process multiple pixels', () => { + const imageData = createImageData([ + [0, 0, 0, 255], + [255, 255, 255, 255], + ]); + applyInvertColors(imageData); + expect(imageData.data[0]).toBe(255); + expect(imageData.data[4]).toBe(0); + }); + }); +}); diff --git a/src/tests/pdf-operations.test.ts b/src/tests/pdf-operations.test.ts new file mode 100644 index 0000000..d3d4ba5 --- /dev/null +++ b/src/tests/pdf-operations.test.ts @@ -0,0 +1,272 @@ +import { describe, it, expect } from 'vitest'; +import { + parsePageRange, + parseDeletePages, + mergePdfs, + splitPdf, + deletePdfPages, + rotatePdfUniform, + rotatePdfPages, +} from '../js/utils/pdf-operations'; +import { PDFDocument } from 'pdf-lib'; + +async function createTestPdf(pageCount: number): Promise { + const doc = await PDFDocument.create(); + for (let i = 0; i < pageCount; i++) { + doc.addPage([612, 792]); + } + return new Uint8Array(await doc.save()); +} + +describe('pdf-operations', () => { + describe('parsePageRange', () => { + it('should parse single page', () => { + expect(parsePageRange('3', 10)).toEqual([2]); + }); + + it('should parse multiple pages', () => { + expect(parsePageRange('1,3,5', 10)).toEqual([0, 2, 4]); + }); + + it('should parse range', () => { + expect(parsePageRange('2-5', 10)).toEqual([1, 2, 3, 4]); + }); + + it('should parse mixed pages and ranges', () => { + expect(parsePageRange('1,3-5,8', 10)).toEqual([0, 2, 3, 4, 7]); + }); + + it('should handle spaces', () => { + expect(parsePageRange(' 1 , 3 - 5 ', 10)).toEqual([0, 2, 3, 4]); + }); + + it('should deduplicate and sort', () => { + expect(parsePageRange('5,3,1,3-5', 10)).toEqual([0, 2, 3, 4]); + }); + + it('should clamp start to 1', () => { + expect(parsePageRange('0-3', 10)).toEqual([0, 1, 2]); + }); + + it('should clamp end to totalPages', () => { + expect(parsePageRange('8-15', 10)).toEqual([7, 8, 9]); + }); + + it('should ignore pages outside bounds', () => { + expect(parsePageRange('0,11,5', 10)).toEqual([4]); + }); + + it('should handle single page document', () => { + expect(parsePageRange('1', 1)).toEqual([0]); + }); + + it('should handle invalid non-numeric input', () => { + expect(parsePageRange('abc', 10)).toEqual([]); + }); + + it('should handle empty parts', () => { + expect(parsePageRange('1,,3', 10)).toEqual([0, 2]); + }); + + it('should handle range with invalid end (NaN defaults to totalPages)', () => { + expect(parsePageRange('3-abc', 10)).toEqual([2, 3, 4, 5, 6, 7, 8, 9]); + }); + }); + + describe('parseDeletePages', () => { + it('should parse single page', () => { + expect(parseDeletePages('3', 10)).toEqual(new Set([3])); + }); + + it('should parse multiple pages', () => { + expect(parseDeletePages('1,3,5', 10)).toEqual(new Set([1, 3, 5])); + }); + + it('should parse range', () => { + expect(parseDeletePages('2-5', 10)).toEqual(new Set([2, 3, 4, 5])); + }); + + it('should parse mixed pages and ranges', () => { + expect(parseDeletePages('1,3-5,8', 10)).toEqual(new Set([1, 3, 4, 5, 8])); + }); + + it('should ignore out-of-bounds pages', () => { + expect(parseDeletePages('0,11,5', 10)).toEqual(new Set([5])); + }); + + it('should handle spaces', () => { + expect(parseDeletePages(' 1 , 3 ', 10)).toEqual(new Set([1, 3])); + }); + + it('should clamp range to valid bounds', () => { + expect(parseDeletePages('0-3', 10)).toEqual(new Set([1, 2, 3])); + }); + + it('should clamp range end to totalPages', () => { + expect(parseDeletePages('8-15', 10)).toEqual(new Set([8, 9, 10])); + }); + + it('should return 1-indexed page numbers', () => { + const result = parseDeletePages('1', 10); + expect(result.has(1)).toBe(true); + expect(result.has(0)).toBe(false); + }); + + it('should handle empty parts gracefully', () => { + expect(parseDeletePages('1,,5', 10)).toEqual(new Set([1, 5])); + }); + }); + + describe('mergePdfs', () => { + it('should merge two single-page PDFs', async () => { + const pdf1 = await createTestPdf(1); + const pdf2 = await createTestPdf(1); + const merged = await mergePdfs([pdf1, pdf2]); + const doc = await PDFDocument.load(merged); + expect(doc.getPageCount()).toBe(2); + }); + + it('should merge multiple PDFs', async () => { + const pdfs = await Promise.all([ + createTestPdf(2), + createTestPdf(3), + createTestPdf(1), + ]); + const merged = await mergePdfs(pdfs); + const doc = await PDFDocument.load(merged); + expect(doc.getPageCount()).toBe(6); + }); + + it('should handle single PDF input', async () => { + const pdf = await createTestPdf(3); + const merged = await mergePdfs([pdf]); + const doc = await PDFDocument.load(merged); + expect(doc.getPageCount()).toBe(3); + }); + + it('should handle empty array', async () => { + const merged = await mergePdfs([]); + const doc = await PDFDocument.load(merged); + expect(doc.getPageCount()).toBe(0); + }); + }); + + describe('splitPdf', () => { + it('should extract specific pages', async () => { + const pdf = await createTestPdf(5); + const split = await splitPdf(pdf, [0, 2, 4]); + const doc = await PDFDocument.load(split); + expect(doc.getPageCount()).toBe(3); + }); + + it('should extract single page', async () => { + const pdf = await createTestPdf(5); + const split = await splitPdf(pdf, [2]); + const doc = await PDFDocument.load(split); + expect(doc.getPageCount()).toBe(1); + }); + + it('should handle extracting all pages', async () => { + const pdf = await createTestPdf(3); + const split = await splitPdf(pdf, [0, 1, 2]); + const doc = await PDFDocument.load(split); + expect(doc.getPageCount()).toBe(3); + }); + }); + + describe('deletePdfPages', () => { + it('should delete specified pages', async () => { + const pdf = await createTestPdf(5); + const result = await deletePdfPages(pdf, new Set([1, 3])); + const doc = await PDFDocument.load(result); + expect(doc.getPageCount()).toBe(3); + }); + + it('should delete single page', async () => { + const pdf = await createTestPdf(3); + const result = await deletePdfPages(pdf, new Set([2])); + const doc = await PDFDocument.load(result); + expect(doc.getPageCount()).toBe(2); + }); + + it('should throw when deleting all pages', async () => { + const pdf = await createTestPdf(2); + await expect(deletePdfPages(pdf, new Set([1, 2]))).rejects.toThrow( + 'Cannot delete all pages' + ); + }); + + it('should ignore out-of-range page numbers', async () => { + const pdf = await createTestPdf(3); + const result = await deletePdfPages(pdf, new Set([5, 10])); + const doc = await PDFDocument.load(result); + expect(doc.getPageCount()).toBe(3); + }); + + it('should handle deleting no pages (empty set)', async () => { + const pdf = await createTestPdf(3); + const result = await deletePdfPages(pdf, new Set()); + const doc = await PDFDocument.load(result); + expect(doc.getPageCount()).toBe(3); + }); + }); + + describe('rotatePdfUniform', () => { + it('should rotate all pages by 90 degrees', async () => { + const pdf = await createTestPdf(3); + const rotated = await rotatePdfUniform(pdf, 90); + const doc = await PDFDocument.load(rotated); + expect(doc.getPageCount()).toBe(3); + expect(doc.getPage(0).getRotation().angle).toBe(90); + }); + + it('should rotate by 180 degrees', async () => { + const pdf = await createTestPdf(2); + const rotated = await rotatePdfUniform(pdf, 180); + const doc = await PDFDocument.load(rotated); + expect(doc.getPage(0).getRotation().angle).toBe(180); + }); + + it('should handle 0 degree rotation', async () => { + const pdf = await createTestPdf(2); + const rotated = await rotatePdfUniform(pdf, 0); + const doc = await PDFDocument.load(rotated); + expect(doc.getPage(0).getRotation().angle).toBe(0); + }); + + it('should rotate by 270 degrees', async () => { + const pdf = await createTestPdf(1); + const rotated = await rotatePdfUniform(pdf, 270); + const doc = await PDFDocument.load(rotated); + expect(doc.getPage(0).getRotation().angle).toBe(270); + }); + }); + + describe('rotatePdfPages', () => { + it('should rotate individual pages by different angles', async () => { + const pdf = await createTestPdf(3); + const rotated = await rotatePdfPages(pdf, [90, 0, 180]); + const doc = await PDFDocument.load(rotated); + expect(doc.getPage(0).getRotation().angle).toBe(90); + expect(doc.getPage(1).getRotation().angle).toBe(0); + expect(doc.getPage(2).getRotation().angle).toBe(180); + }); + + it('should treat missing rotations as 0', async () => { + const pdf = await createTestPdf(3); + const rotated = await rotatePdfPages(pdf, [90]); + const doc = await PDFDocument.load(rotated); + expect(doc.getPage(0).getRotation().angle).toBe(90); + expect(doc.getPage(1).getRotation().angle).toBe(0); + expect(doc.getPage(2).getRotation().angle).toBe(0); + }); + + it('should handle all zeros', async () => { + const pdf = await createTestPdf(2); + const rotated = await rotatePdfPages(pdf, [0, 0]); + const doc = await PDFDocument.load(rotated); + expect(doc.getPage(0).getRotation().angle).toBe(0); + expect(doc.getPage(1).getRotation().angle).toBe(0); + }); + }); +}); diff --git a/src/tests/pdf-tools.test.ts b/src/tests/pdf-tools.test.ts index 34b5d0e..cee6868 100644 --- a/src/tests/pdf-tools.test.ts +++ b/src/tests/pdf-tools.test.ts @@ -19,7 +19,7 @@ describe('Tool Configuration Arrays', () => { it('should have the correct number of tools', () => { // This acts as a snapshot test to catch unexpected additions/removals. - expect(singlePdfLoadTools).toHaveLength(41); + expect(singlePdfLoadTools).toHaveLength(42); }); it('should not contain any duplicate tools', () => { @@ -61,7 +61,7 @@ describe('Tool Configuration Arrays', () => { }); it('should have the correct number of tools', () => { - expect(multiFileTools).toHaveLength(13); + expect(multiFileTools).toHaveLength(18); }); it('should not contain any duplicate tools', () => { diff --git a/src/tests/rotation-state.test.ts b/src/tests/rotation-state.test.ts new file mode 100644 index 0000000..afca040 --- /dev/null +++ b/src/tests/rotation-state.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + getRotationState, + updateRotationState, + resetRotationState, + initializeRotationState, +} from '../js/utils/rotation-state'; + +describe('rotation-state', () => { + beforeEach(() => { + resetRotationState(); + }); + + describe('initializeRotationState', () => { + it('should create array of zeros for given page count', () => { + initializeRotationState(5); + expect(getRotationState()).toEqual([0, 0, 0, 0, 0]); + }); + + it('should handle single page', () => { + initializeRotationState(1); + expect(getRotationState()).toEqual([0]); + }); + + it('should handle zero pages', () => { + initializeRotationState(0); + expect(getRotationState()).toEqual([]); + }); + + it('should reset previous state on re-initialization', () => { + initializeRotationState(3); + updateRotationState(0, 90); + initializeRotationState(5); + expect(getRotationState()).toEqual([0, 0, 0, 0, 0]); + }); + }); + + describe('getRotationState', () => { + it('should return empty array before initialization', () => { + expect(getRotationState()).toEqual([]); + }); + + it('should return readonly array', () => { + initializeRotationState(3); + const state = getRotationState(); + expect(Array.isArray(state)).toBe(true); + }); + + it('should reflect current state after updates', () => { + initializeRotationState(3); + updateRotationState(1, 180); + expect(getRotationState()[1]).toBe(180); + }); + }); + + describe('updateRotationState', () => { + it('should update rotation at valid index', () => { + initializeRotationState(3); + updateRotationState(0, 90); + expect(getRotationState()[0]).toBe(90); + }); + + it('should update to any rotation value', () => { + initializeRotationState(3); + updateRotationState(0, 90); + updateRotationState(1, 180); + updateRotationState(2, 270); + expect(getRotationState()).toEqual([90, 180, 270]); + }); + + it('should handle negative rotation', () => { + initializeRotationState(2); + updateRotationState(0, -90); + expect(getRotationState()[0]).toBe(-90); + }); + + it('should ignore negative index', () => { + initializeRotationState(3); + updateRotationState(-1, 90); + expect(getRotationState()).toEqual([0, 0, 0]); + }); + + it('should ignore out-of-bounds index', () => { + initializeRotationState(3); + updateRotationState(5, 90); + expect(getRotationState()).toEqual([0, 0, 0]); + }); + + it('should ignore update on empty state', () => { + updateRotationState(0, 90); + expect(getRotationState()).toEqual([]); + }); + + it('should allow overwriting a previously set rotation', () => { + initializeRotationState(2); + updateRotationState(0, 90); + updateRotationState(0, 180); + expect(getRotationState()[0]).toBe(180); + }); + }); + + describe('resetRotationState', () => { + it('should clear all state', () => { + initializeRotationState(5); + updateRotationState(2, 270); + resetRotationState(); + expect(getRotationState()).toEqual([]); + }); + + it('should be safe to call on empty state', () => { + resetRotationState(); + expect(getRotationState()).toEqual([]); + }); + + it('should allow re-initialization after reset', () => { + initializeRotationState(3); + resetRotationState(); + initializeRotationState(2); + expect(getRotationState()).toEqual([0, 0]); + }); + }); +}); diff --git a/src/tests/setup.ts b/src/tests/setup.ts index dc4c349..9cb4e3e 100644 --- a/src/tests/setup.ts +++ b/src/tests/setup.ts @@ -1,5 +1,18 @@ import { afterEach, vi } from 'vitest'; +class TestDOMMatrix { + a = 1; + b = 0; + c = 0; + d = 1; + e = 0; + f = 0; +} + +if (typeof globalThis.DOMMatrix === 'undefined') { + globalThis.DOMMatrix = TestDOMMatrix as unknown as typeof DOMMatrix; +} + afterEach(() => { document.body.innerHTML = ''; document.head.innerHTML = ''; diff --git a/src/tests/xml-to-pdf.test.ts b/src/tests/xml-to-pdf.test.ts new file mode 100644 index 0000000..00aefd6 --- /dev/null +++ b/src/tests/xml-to-pdf.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect } from 'vitest'; + +function groupByTagName(elements: Element[]): Record { + const groups: Record = {}; + for (const element of elements) { + const tagName = element.tagName; + if (!groups[tagName]) { + groups[tagName] = []; + } + groups[tagName].push(element); + } + return groups; +} + +function extractTableData(elements: Element[]): { + headers: string[]; + rows: string[][]; +} { + if (elements.length === 0) { + return { headers: [], rows: [] }; + } + const headerSet = new Set(); + for (const element of elements) { + for (const child of Array.from(element.children)) { + headerSet.add(child.tagName); + } + } + const headers = Array.from(headerSet); + const rows: string[][] = []; + for (const element of elements) { + const row: string[] = []; + for (const header of headers) { + const child = element.querySelector(header); + row.push(child?.textContent?.trim() || ''); + } + rows.push(row); + } + return { headers, rows }; +} + +function extractKeyValuePairs(element: Element): string[][] { + const pairs: string[][] = []; + for (const child of Array.from(element.children)) { + const key = child.tagName; + const value = child.textContent?.trim() || ''; + if (value) { + pairs.push([formatTitle(key), value]); + } + } + for (const attr of Array.from(element.attributes)) { + pairs.push([formatTitle(attr.name), attr.value]); + } + return pairs; +} + +function formatTitle(tagName: string): string { + return tagName + .replace(/[_-]/g, ' ') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); +} + +function parseXml(xmlString: string): Document { + return new DOMParser().parseFromString(xmlString, 'text/xml'); +} + +describe('xml-to-pdf utilities', () => { + describe('formatTitle', () => { + it('should convert underscores to spaces and capitalize', () => { + expect(formatTitle('first_name')).toBe('First Name'); + }); + + it('should convert hyphens to spaces and capitalize', () => { + expect(formatTitle('last-name')).toBe('Last Name'); + }); + + it('should split camelCase', () => { + expect(formatTitle('firstName')).toBe('First Name'); + }); + + it('should handle single word', () => { + expect(formatTitle('name')).toBe('Name'); + }); + + it('should handle all caps', () => { + expect(formatTitle('ID')).toBe('Id'); + }); + + it('should handle mixed separators', () => { + expect(formatTitle('user_firstName')).toBe('User First Name'); + }); + + it('should handle empty string', () => { + expect(formatTitle('')).toBe(''); + }); + + it('should handle multiple underscores', () => { + expect(formatTitle('a_b_c')).toBe('A B C'); + }); + + it('should lowercase subsequent characters', () => { + expect(formatTitle('XML')).toBe('Xml'); + }); + }); + + describe('groupByTagName', () => { + it('should group elements by tag name', () => { + const doc = parseXml( + '123' + ); + const children = Array.from(doc.documentElement.children); + const groups = groupByTagName(children); + expect(Object.keys(groups)).toEqual(['item', 'other']); + expect(groups['item'].length).toBe(2); + expect(groups['other'].length).toBe(1); + }); + + it('should handle empty array', () => { + expect(groupByTagName([])).toEqual({}); + }); + + it('should handle single element', () => { + const doc = parseXml('1'); + const children = Array.from(doc.documentElement.children); + const groups = groupByTagName(children); + expect(Object.keys(groups)).toEqual(['item']); + expect(groups['item'].length).toBe(1); + }); + + it('should handle all same tag names', () => { + const doc = parseXml('123'); + const children = Array.from(doc.documentElement.children); + const groups = groupByTagName(children); + expect(Object.keys(groups)).toEqual(['row']); + expect(groups['row'].length).toBe(3); + }); + + it('should handle all different tag names', () => { + const doc = parseXml('123'); + const children = Array.from(doc.documentElement.children); + const groups = groupByTagName(children); + expect(Object.keys(groups).length).toBe(3); + }); + }); + + describe('extractTableData', () => { + it('should extract headers and rows from elements', () => { + const doc = parseXml(` + + Alice30 + Bob25 + + `); + const elements = Array.from(doc.querySelectorAll('person')); + const { headers, rows } = extractTableData(elements); + expect(headers).toEqual(['name', 'age']); + expect(rows).toEqual([ + ['Alice', '30'], + ['Bob', '25'], + ]); + }); + + it('should handle empty array', () => { + expect(extractTableData([])).toEqual({ headers: [], rows: [] }); + }); + + it('should handle missing children in some elements', () => { + const doc = parseXml(` + + 12 + 3 + + `); + const elements = Array.from(doc.querySelectorAll('item')); + const { headers, rows } = extractTableData(elements); + expect(headers).toEqual(['a', 'b']); + expect(rows[1]).toEqual(['3', '']); + }); + + it('should handle elements with no children', () => { + const doc = parseXml(''); + const elements = Array.from(doc.querySelectorAll('item')); + const { headers, rows } = extractTableData(elements); + expect(headers).toEqual([]); + expect(rows).toEqual([[]]); + }); + + it('should collect headers from all elements', () => { + const doc = parseXml(` + + 1 + 2 + + `); + const elements = Array.from(doc.querySelectorAll('item')); + const { headers } = extractTableData(elements); + expect(headers).toContain('a'); + expect(headers).toContain('b'); + }); + + it('should trim whitespace from text content', () => { + const doc = parseXml(' hello '); + const elements = Array.from(doc.querySelectorAll('item')); + const { rows } = extractTableData(elements); + expect(rows[0][0]).toBe('hello'); + }); + }); + + describe('extractKeyValuePairs', () => { + it('should extract child elements as key-value pairs', () => { + const doc = parseXml( + 'Test1.0' + ); + const pairs = extractKeyValuePairs(doc.documentElement); + expect(pairs).toEqual([ + ['Name', 'Test'], + ['Version', '1.0'], + ]); + }); + + it('should extract attributes as key-value pairs', () => { + const doc = parseXml(''); + const pairs = extractKeyValuePairs(doc.documentElement); + expect(pairs).toContainEqual(['Id', '123']); + expect(pairs).toContainEqual(['Type', 'main']); + }); + + it('should include both children and attributes', () => { + const doc = parseXml('Test'); + const pairs = extractKeyValuePairs(doc.documentElement); + expect(pairs.length).toBe(2); + }); + + it('should skip empty child text content', () => { + const doc = parseXml('Test'); + const pairs = extractKeyValuePairs(doc.documentElement); + expect(pairs.length).toBe(1); + expect(pairs[0][0]).toBe('Name'); + }); + + it('should handle element with no children or attributes', () => { + const doc = parseXml(''); + const pairs = extractKeyValuePairs(doc.documentElement); + expect(pairs).toEqual([]); + }); + + it('should format tag names using formatTitle', () => { + const doc = parseXml('Alice'); + const pairs = extractKeyValuePairs(doc.documentElement); + expect(pairs[0][0]).toBe('User Name'); + }); + }); +});