diff --git a/src/js/utils/pdf-operations.ts b/src/js/utils/pdf-operations.ts index 08dbdd6..a048ee8 100644 --- a/src/js/utils/pdf-operations.ts +++ b/src/js/utils/pdf-operations.ts @@ -489,12 +489,26 @@ export async function fixPageSize( const { width: sourceWidth, height: sourceHeight } = sourcePage.getSize(); const embeddedPage = await outputDoc.embedPage(sourcePage); - const outputPage = outputDoc.addPage([targetWidth, targetHeight]); + let pageTargetWidth = targetWidth; + let pageTargetHeight = targetHeight; + + if (orientation === 'auto') { + const isSourceLandscape = sourceWidth > sourceHeight; + const isTargetLandscape = pageTargetWidth > pageTargetHeight; + if (isSourceLandscape !== isTargetLandscape) { + [pageTargetWidth, pageTargetHeight] = [ + pageTargetHeight, + pageTargetWidth, + ]; + } + } + + const outputPage = outputDoc.addPage([pageTargetWidth, pageTargetHeight]); outputPage.drawRectangle({ x: 0, y: 0, - width: targetWidth, - height: targetHeight, + width: pageTargetWidth, + height: pageTargetHeight, color: rgb( options.backgroundColor.r, options.backgroundColor.g, @@ -502,16 +516,16 @@ export async function fixPageSize( ), }); - const scaleX = targetWidth / sourceWidth; - const scaleY = targetHeight / sourceHeight; + const scaleX = pageTargetWidth / sourceWidth; + const scaleY = pageTargetHeight / sourceHeight; const useFill = options.scalingMode.toLowerCase() === 'fill'; const scale = useFill ? Math.max(scaleX, scaleY) : Math.min(scaleX, scaleY); const scaledWidth = sourceWidth * scale; const scaledHeight = sourceHeight * scale; - const x = (targetWidth - scaledWidth) / 2; - const y = (targetHeight - scaledHeight) / 2; + const x = (pageTargetWidth - scaledWidth) / 2; + const y = (pageTargetHeight - scaledHeight) / 2; outputPage.drawPage(embeddedPage, { x, diff --git a/src/tests/fix-page-size.test.ts b/src/tests/fix-page-size.test.ts new file mode 100644 index 0000000..7787549 --- /dev/null +++ b/src/tests/fix-page-size.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect } from 'vitest'; +import { fixPageSize, FixPageSizeOptions } from '../js/utils/pdf-operations'; +import { PDFDocument, PageSizes, rgb } from 'pdf-lib'; + +const WHITE = { r: 1, g: 1, b: 1 }; + +function opts(overrides: Partial = {}): FixPageSizeOptions { + return { + targetSize: 'A4', + orientation: 'auto', + scalingMode: 'fit', + backgroundColor: WHITE, + ...overrides, + }; +} + +async function createPdf(pages: [number, number][]): Promise { + const doc = await PDFDocument.create(); + for (const [w, h] of pages) { + const page = doc.addPage([w, h]); + page.drawRectangle({ + x: 0, + y: 0, + width: w, + height: h, + color: rgb(1, 1, 1), + }); + } + return new Uint8Array(await doc.save()); +} + +async function getPageSizes(pdfBytes: Uint8Array): Promise<[number, number][]> { + const doc = await PDFDocument.load(pdfBytes); + return doc.getPages().map((p) => { + const { width, height } = p.getSize(); + return [Math.round(width), Math.round(height)]; + }); +} + +const A4_W = Math.round(PageSizes.A4[0]); +const A4_H = Math.round(PageSizes.A4[1]); + +describe('fixPageSize', () => { + describe('orientation: auto', () => { + it('keeps portrait target for portrait source', async () => { + const pdf = await createPdf([[400, 600]]); + const result = await fixPageSize(pdf, opts({ orientation: 'auto' })); + const sizes = await getPageSizes(result); + expect(sizes[0][0]).toBeLessThan(sizes[0][1]); + }); + + it('swaps to landscape target for landscape source', async () => { + const pdf = await createPdf([[800, 400]]); + const result = await fixPageSize(pdf, opts({ orientation: 'auto' })); + const sizes = await getPageSizes(result); + expect(sizes[0][0]).toBeGreaterThan(sizes[0][1]); + }); + + it('handles mixed portrait and landscape pages', async () => { + const pdf = await createPdf([ + [400, 600], + [800, 400], + [300, 500], + ]); + const result = await fixPageSize(pdf, opts({ orientation: 'auto' })); + const sizes = await getPageSizes(result); + expect(sizes[0][0]).toBeLessThan(sizes[0][1]); + expect(sizes[1][0]).toBeGreaterThan(sizes[1][1]); + expect(sizes[2][0]).toBeLessThan(sizes[2][1]); + }); + + it('keeps square pages as portrait target', async () => { + const pdf = await createPdf([[500, 500]]); + const result = await fixPageSize(pdf, opts({ orientation: 'auto' })); + const sizes = await getPageSizes(result); + expect(sizes[0][0]).toBe(A4_W); + expect(sizes[0][1]).toBe(A4_H); + }); + }); + + describe('orientation: portrait', () => { + it('forces portrait regardless of source orientation', async () => { + const pdf = await createPdf([[800, 400]]); + const result = await fixPageSize(pdf, opts({ orientation: 'portrait' })); + const sizes = await getPageSizes(result); + expect(sizes[0][0]).toBeLessThan(sizes[0][1]); + }); + + it('keeps portrait for portrait source', async () => { + const pdf = await createPdf([[400, 600]]); + const result = await fixPageSize(pdf, opts({ orientation: 'portrait' })); + const sizes = await getPageSizes(result); + expect(sizes[0][0]).toBe(A4_W); + expect(sizes[0][1]).toBe(A4_H); + }); + }); + + describe('orientation: landscape', () => { + it('forces landscape regardless of source orientation', async () => { + const pdf = await createPdf([[400, 600]]); + const result = await fixPageSize(pdf, opts({ orientation: 'landscape' })); + const sizes = await getPageSizes(result); + expect(sizes[0][0]).toBeGreaterThan(sizes[0][1]); + }); + + it('keeps landscape for landscape source', async () => { + const pdf = await createPdf([[800, 400]]); + const result = await fixPageSize(pdf, opts({ orientation: 'landscape' })); + const sizes = await getPageSizes(result); + expect(sizes[0][0]).toBe(A4_H); + expect(sizes[0][1]).toBe(A4_W); + }); + }); + + describe('target size: predefined', () => { + it('uses A4 dimensions', async () => { + const pdf = await createPdf([[400, 600]]); + const result = await fixPageSize(pdf, opts({ targetSize: 'A4' })); + const sizes = await getPageSizes(result); + expect(sizes[0]).toEqual([A4_W, A4_H]); + }); + + it('uses Letter dimensions', async () => { + const pdf = await createPdf([[400, 600]]); + const result = await fixPageSize(pdf, opts({ targetSize: 'Letter' })); + const sizes = await getPageSizes(result); + expect(sizes[0]).toEqual([ + Math.round(PageSizes.Letter[0]), + Math.round(PageSizes.Letter[1]), + ]); + }); + + it('falls back to A4 for unknown size', async () => { + const pdf = await createPdf([[400, 600]]); + const result = await fixPageSize(pdf, opts({ targetSize: 'Unknown' })); + const sizes = await getPageSizes(result); + expect(sizes[0]).toEqual([A4_W, A4_H]); + }); + }); + + describe('target size: custom', () => { + it('uses custom mm dimensions', async () => { + const pdf = await createPdf([[400, 600]]); + const result = await fixPageSize( + pdf, + opts({ + targetSize: 'custom', + customWidth: 100, + customHeight: 200, + customUnits: 'mm', + }) + ); + const sizes = await getPageSizes(result); + const expectedW = Math.round(100 * (72 / 25.4)); + const expectedH = Math.round(200 * (72 / 25.4)); + expect(sizes[0]).toEqual([expectedW, expectedH]); + }); + + it('uses custom inch dimensions', async () => { + const pdf = await createPdf([[400, 600]]); + const result = await fixPageSize( + pdf, + opts({ + targetSize: 'custom', + customWidth: 5, + customHeight: 8, + customUnits: 'in', + }) + ); + const sizes = await getPageSizes(result); + expect(sizes[0]).toEqual([360, 576]); + }); + + it('defaults custom dimensions to 210x297mm when not provided', async () => { + const pdf = await createPdf([[400, 600]]); + const result = await fixPageSize(pdf, opts({ targetSize: 'custom' })); + const sizes = await getPageSizes(result); + expect(sizes[0]).toEqual([A4_W, A4_H]); + }); + }); + + describe('scaling mode', () => { + it('preserves page count', async () => { + const pdf = await createPdf([ + [400, 600], + [500, 700], + [300, 400], + ]); + const result = await fixPageSize(pdf, opts({ scalingMode: 'fit' })); + const sizes = await getPageSizes(result); + expect(sizes.length).toBe(3); + }); + + it('fit mode does not exceed target dimensions', async () => { + const pdf = await createPdf([[1000, 500]]); + const result = await fixPageSize( + pdf, + opts({ orientation: 'landscape', scalingMode: 'fit' }) + ); + const doc = await PDFDocument.load(result); + const page = doc.getPages()[0]; + const { width, height } = page.getSize(); + expect(width).toBeLessThanOrEqual(A4_H + 1); + expect(height).toBeLessThanOrEqual(A4_W + 1); + }); + }); + + describe('single page', () => { + it('converts single page PDF', async () => { + const pdf = await createPdf([[612, 792]]); + const result = await fixPageSize(pdf, opts()); + const sizes = await getPageSizes(result); + expect(sizes.length).toBe(1); + }); + }); + + describe('auto orientation with custom size', () => { + it('swaps custom dimensions for landscape source', async () => { + const pdf = await createPdf([[800, 400]]); + const result = await fixPageSize( + pdf, + opts({ + targetSize: 'custom', + customWidth: 100, + customHeight: 200, + customUnits: 'mm', + orientation: 'auto', + }) + ); + const sizes = await getPageSizes(result); + expect(sizes[0][0]).toBeGreaterThan(sizes[0][1]); + }); + + it('keeps custom dimensions for portrait source', async () => { + const pdf = await createPdf([[400, 800]]); + const result = await fixPageSize( + pdf, + opts({ + targetSize: 'custom', + customWidth: 100, + customHeight: 200, + customUnits: 'mm', + orientation: 'auto', + }) + ); + const sizes = await getPageSizes(result); + expect(sizes[0][0]).toBeLessThan(sizes[0][1]); + }); + }); +});