UNPKG

@pdfme/common

Version:

TypeScript base PDF generator and React base UI. Open source, developed by the community, and completely free to use under the MIT license!

224 lines 9.34 kB
import { cloneDeep, isBlankPdf } from './helper.js'; /** Floating point tolerance for comparisons */ const EPSILON = 0.01; /** Calculate the content height of a page (drawable area excluding padding) */ const getContentHeight = (basePdf) => basePdf.height - basePdf.padding[0] - basePdf.padding[2]; /** Get the input value for a schema */ const getSchemaValue = (schema, input) => (schema.readOnly ? schema.content : input?.[schema.name]) || ''; /** * Normalize schemas within a single page into layout items. * Returns items sorted by Y coordinate with their order preserved. */ function normalizePageSchemas(pageSchemas, paddingTop) { const items = []; const orderMap = new Map(); pageSchemas.forEach((schema, index) => { const localY = schema.position.y - paddingTop; items.push({ schema: cloneDeep(schema), baseY: localY, height: schema.height, dynamicHeights: [schema.height], // Will be updated later }); orderMap.set(schema.name, index); }); // Sort by Y coordinate (preserve original order for same position) items.sort((a, b) => { if (Math.abs(a.baseY - b.baseY) > EPSILON) { return a.baseY - b.baseY; } return (orderMap.get(a.schema.name) ?? 0) - (orderMap.get(b.schema.name) ?? 0); }); return { items, orderMap }; } /** * Place rows on pages, splitting across pages as needed. * @returns The final global Y coordinate after placement */ function placeRowsOnPages(schema, dynamicHeights, startGlobalY, contentHeight, paddingTop, pages) { let currentRowIndex = 0; let currentPageIndex = Math.floor(startGlobalY / contentHeight); let currentYInPage = startGlobalY % contentHeight; if (currentYInPage < 0) currentYInPage = 0; let actualGlobalEndY = 0; const isSplittable = dynamicHeights.length > 1; while (currentRowIndex < dynamicHeights.length) { // Ensure page exists while (pages.length <= currentPageIndex) pages.push([]); const spaceLeft = contentHeight - currentYInPage; const rowHeight = dynamicHeights[currentRowIndex]; // If row doesn't fit, move to next page if (rowHeight > spaceLeft + EPSILON) { const isAtPageStart = Math.abs(spaceLeft - contentHeight) <= EPSILON; if (!isAtPageStart) { currentPageIndex++; currentYInPage = 0; continue; } // Force placement for oversized rows that don't fit even on a fresh page } // Pack as many rows as possible on this page let chunkHeight = 0; const startRowIndex = currentRowIndex; while (currentRowIndex < dynamicHeights.length) { const h = dynamicHeights[currentRowIndex]; if (currentYInPage + chunkHeight + h <= contentHeight + EPSILON) { chunkHeight += h; currentRowIndex++; } else { break; } } // Don't leave header alone on a page without any data rows // If only header fits and there are data rows remaining, move everything to next page // BUT: if already at page top, don't move (prevents infinite loop when data row is too large) const isAtPageTop = currentYInPage <= EPSILON; if (isSplittable && startRowIndex === 0 && currentRowIndex === 1 && dynamicHeights.length > 1 && !isAtPageTop) { currentRowIndex = 0; currentPageIndex++; currentYInPage = 0; continue; } // Force at least one row to prevent infinite loop if (currentRowIndex === startRowIndex) { chunkHeight += dynamicHeights[currentRowIndex]; currentRowIndex++; } // Create schema for this chunk const newSchema = { ...schema, height: chunkHeight, position: { ...schema.position, y: currentYInPage + paddingTop }, }; // Set bodyRange for splittable elements // dynamicHeights[0] = header row, dynamicHeights[1] = body[0] // So subtract 1 to convert to body index if (isSplittable) { newSchema.__bodyRange = { start: startRowIndex === 0 ? 0 : startRowIndex - 1, end: currentRowIndex - 1, }; newSchema.__isSplit = startRowIndex > 0; } pages[currentPageIndex].push(newSchema); // Update position currentYInPage += chunkHeight; if (currentYInPage >= contentHeight - EPSILON) { currentPageIndex++; currentYInPage = 0; } actualGlobalEndY = currentPageIndex * contentHeight + currentYInPage; } return actualGlobalEndY; } /** Sort elements within each page by their original order */ function sortPagesByOrder(pages, orderMap) { pages.forEach((page) => { page.sort((a, b) => (orderMap.get(a.name) ?? 0) - (orderMap.get(b.name) ?? 0)); }); } /** Remove trailing empty pages */ function removeTrailingEmptyPages(pages) { while (pages.length > 1 && pages[pages.length - 1].length === 0) { pages.pop(); } } /** * Process a single template page that has dynamic content. * Uses the same layout algorithm as the original implementation, * but scoped to a single page's schemas. */ function processDynamicPage(items, orderMap, contentHeight, paddingTop) { const pages = []; let totalYOffset = 0; for (const item of items) { const currentGlobalStartY = item.baseY + totalYOffset; const actualGlobalEndY = placeRowsOnPages(item.schema, item.dynamicHeights, currentGlobalStartY, contentHeight, paddingTop, pages); // Update offset: difference between actual and original end position const originalGlobalEndY = item.baseY + item.height; totalYOffset = actualGlobalEndY - originalGlobalEndY; } sortPagesByOrder(pages, orderMap); removeTrailingEmptyPages(pages); return pages; } /** * Process a template containing tables with dynamic heights * and generate a new template with proper page breaks. * * Processing is done page-by-page: * - Pages with height changes are processed with full layout calculations * - Pages without height changes are copied as-is (no offset propagation between pages) * * This reduces computation cost by: * 1. Limiting layout calculations to pages that need them * 2. Avoiding cross-page offset propagation for static pages */ export const getDynamicTemplate = async (arg) => { const { template, input, options, _cache, getDynamicHeights } = arg; const basePdf = template.basePdf; if (!isBlankPdf(basePdf)) { return template; } const contentHeight = getContentHeight(basePdf); const paddingTop = basePdf.padding[0]; const resultPages = []; const PARALLEL_LIMIT = 10; // Process each template page independently for (let pageIndex = 0; pageIndex < template.schemas.length; pageIndex++) { const pageSchemas = template.schemas[pageIndex]; // Normalize this page's schemas const { items, orderMap } = normalizePageSchemas(pageSchemas, paddingTop); // Calculate dynamic heights for this page's schemas with concurrency limit for (let i = 0; i < items.length; i += PARALLEL_LIMIT) { const chunk = items.slice(i, i + PARALLEL_LIMIT); const chunkResults = await Promise.all(chunk.map((item) => { const value = getSchemaValue(item.schema, input); return getDynamicHeights(value, { schema: item.schema, basePdf, options, _cache, }).then((heights) => (heights.length === 0 ? [0] : heights)); })); // Update items with calculated heights for (let j = 0; j < chunkResults.length; j++) { items[i + j].dynamicHeights = chunkResults[j]; } } // Process all pages independently (no cross-page offset propagation) const processedPages = processDynamicPage(items, orderMap, contentHeight, paddingTop); resultPages.push(...processedPages); } removeTrailingEmptyPages(resultPages); // Check if anything changed - return original template if not if (resultPages.length === template.schemas.length) { let unchanged = true; for (let i = 0; i < resultPages.length && unchanged; i++) { if (resultPages[i].length !== template.schemas[i].length) { unchanged = false; break; } for (let j = 0; j < resultPages[i].length && unchanged; j++) { const orig = template.schemas[i][j]; const result = resultPages[i][j]; if (Math.abs(orig.height - result.height) > EPSILON || Math.abs(orig.position.y - result.position.y) > EPSILON) { unchanged = false; } } } if (unchanged) { return template; } } return { basePdf, schemas: resultPages }; }; //# sourceMappingURL=dynamicTemplate.js.map