diff --git a/opencti-platform/opencti-front/package.json b/opencti-platform/opencti-front/package.json index 761612084be5..be04ab598861 100644 --- a/opencti-platform/opencti-front/package.json +++ b/opencti-platform/opencti-front/package.json @@ -174,6 +174,7 @@ "knip": "6.35.1", "license-checker-rseidelsohn": "5.0.1", "monocart-reporter": "2.13.1", + "pdfjs-dist": "5.4.296", "relay-compiler": "21.0.1", "relay-test-utils": "21.0.1", "typescript": "6.0.3", @@ -207,4 +208,4 @@ "built": false } } -} +} \ No newline at end of file diff --git a/opencti-platform/opencti-front/src/components/ExportButtons.test.tsx b/opencti-platform/opencti-front/src/components/ExportButtons.test.tsx index 6ee8d61465fa..3017a34dc881 100644 --- a/opencti-platform/opencti-front/src/components/ExportButtons.test.tsx +++ b/opencti-platform/opencti-front/src/components/ExportButtons.test.tsx @@ -15,6 +15,7 @@ vi.mock('../utils/Image', () => ({ // Mock MESSAGING$ so we can assert error notifications // Use a Proxy for environment so any method call (retain, check, etc.) is auto-mocked vi.mock('../relay/environment', () => ({ + APP_BASE_PATH: '', MESSAGING$: { notifyError: vi.fn() }, environment: new Proxy({}, { get: () => vi.fn(() => ({ dispose: vi.fn() })), diff --git a/opencti-platform/opencti-front/src/utils/htmlToPdf/htmlToPdf.ts b/opencti-platform/opencti-front/src/utils/htmlToPdf/htmlToPdf.ts index 48105c34c76d..a2cf87bd77af 100644 --- a/opencti-platform/opencti-front/src/utils/htmlToPdf/htmlToPdf.ts +++ b/opencti-platform/opencti-front/src/utils/htmlToPdf/htmlToPdf.ts @@ -79,6 +79,7 @@ export const htmlToPdf = ( if (fileName && fileName.endsWith('.md')) { htmlData = renderToString(compiler(htmlData, { wrapper: null })); } + htmlData = setTableFullWidth(htmlData); // Detect CJK characters and pick a font that has CJK glyphs. // Roboto (the pdfmake default) has no CJK glyphs, so Japanese/Korean text @@ -215,7 +216,8 @@ export const htmlToPdfReport = async ( const pageSize: PdfPageSize = containsVeryWideTable ? 'A3' : 'A4'; const pageOrientation: PdfPageOrientation = containsWideTable ? 'landscape' : 'portrait'; const { pageWidth, pageHeight, backPageLogoMarginTop } = resolvePdfPageGeometry(pageSize, pageOrientation); - htmlData = setTableFullWidth(htmlData); + const pageMargins: [number, number] = containsVeryWideTable ? [8, 12] : containsWideTable ? [10, 20] : [20, 30]; + htmlData = setTableFullWidth(htmlData, pageWidth - 2 * pageMargins[0]); htmlData = addPageBreaks(htmlData); const selectedFont = detectLanguage(htmlData); @@ -300,7 +302,7 @@ export const htmlToPdfReport = async ( ]; const docDefinition: TDocumentDefinitions = { - pageMargins: containsVeryWideTable ? [8, 12] : containsWideTable ? [10, 20] : [20, 30], + pageMargins, pageSize, pageOrientation, styles: { diff --git a/opencti-platform/opencti-front/src/utils/htmlToPdf/utils/pdfTableWidth.test.ts b/opencti-platform/opencti-front/src/utils/htmlToPdf/utils/pdfTableWidth.test.ts index f215c9403d3f..e59439a536ea 100644 --- a/opencti-platform/opencti-front/src/utils/htmlToPdf/utils/pdfTableWidth.test.ts +++ b/opencti-platform/opencti-front/src/utils/htmlToPdf/utils/pdfTableWidth.test.ts @@ -1,7 +1,110 @@ -import { describe, expect, it } from 'vitest'; -import setTableFullWidth, { getMaxTableColumnCount, hasWideTable } from './pdfTableWidth'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import htmlToPdfmake from 'html-to-pdfmake'; +import pdfMake from 'pdfmake/build/pdfmake'; +import fonts from 'pdfmake/build/vfs_fonts'; +import { getDocument, OPS, Util } from 'pdfjs-dist/legacy/build/pdf.mjs'; +import setTableFullWidth, { defaultTableLayout, getMaxTableColumnCount, hasWideTable } from './pdfTableWidth'; +import { htmlToPdf, htmlToPdfReport } from '../htmlToPdf'; + +beforeAll(async () => { + pdfMake.addVirtualFileSystem(fonts); + const pdf = pdfMake.createPdf({ content: '' }); + const stream = await pdf.getStream() as unknown as { + provideFont: (family: string, bold: boolean, italics: boolean) => { + widthOfString: (text: string, size: number) => number; + }; + }; + const measurement = { + font: '12pt Roboto', + measureText(text: string) { + const size = Number(this.font.match(/([\d.]+)pt/)?.[1] ?? 12); + return { width: stream.provideFont('Roboto', this.font.includes('bold'), false).widthOfString(text, size) / 0.75 }; + }, + }; + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(measurement as unknown as CanvasRenderingContext2D); + await pdf.getBuffer(); +}); + +afterAll(() => vi.restoreAllMocks()); describe('Utils: setTableFullWidth', () => { + const prepareTable = (html: string, contentWidth = 515.28) => { + const container = document.createElement('div'); + container.innerHTML = setTableFullWidth(html, contentWidth); + const table = container.querySelector('table')!; + const definition = JSON.parse(table.getAttribute('data-pdfmake')!); + const percentages = (definition.widths as string[]).map((width) => Number.parseFloat(width)); + return { table, percentages }; + }; + + it('gives longer descriptions more space even when both require wrapping', () => { + const { percentages } = prepareTable(`
ID${'Description '.repeat(10)}${'Description '.repeat(60)}
`); + expect(percentages[2]).toBeGreaterThan(percentages[1] + 5); + expect(percentages.reduce((total, width) => total + width, 0)).toBeCloseTo(100, 8); + }); + + it('measures glyph widths and includes headers in the allocation', () => { + const { percentages } = prepareTable('
WWWWWWWWiiiiiiii
11
'); + expect(percentages[0]).toBeGreaterThan(percentages[1]); + }); + + it.each([1, 2, 4, 8, 12])('keeps balanced content balanced across %i columns', (columnCount) => { + const { percentages } = prepareTable(`${''.repeat(columnCount)}
Equal content
`); + percentages.forEach((width) => expect(width).toBeCloseTo(100 / columnCount, 8)); + }); + + it.each([2, 4, 8, 12])('bounds outlier demand across %i columns', (columnCount) => { + const { percentages } = prepareTable(`${''.repeat(columnCount - 1)}
${'abcdef0123456789'.repeat(200)}ID
`); + expect(percentages.reduce((total, width) => total + width, 0)).toBeCloseTo(100, 8); + expect(percentages[0]).toBeGreaterThan(100 / columnCount); + percentages.forEach((width) => { + expect(width).toBeGreaterThanOrEqual(50 / columnCount - 0.01); + expect(width).toBeLessThanOrEqual(Math.min(60, 200 / columnCount) + 0.01); + }); + }); + + it('limits images to the sum of their allocated spanned columns', () => { + const { table, percentages } = prepareTable(`
ID${'Description '.repeat(20)}Status
42
`, 500); + const imageWidth = Number.parseFloat(table.querySelector('img')!.style.maxWidth); + expect(imageWidth).toBeCloseTo(500 * (percentages[1] + percentages[2]) / 100 - 22, 8); + }); + + it('assigns images to the correct column after a row-spanning cell', () => { + const { table, percentages } = prepareTable(`
${'Description '.repeat(20)}Status
`, 500); + const imageWidth = Number.parseFloat(table.querySelector('img')!.style.maxWidth); + expect(percentages[0]).toBeGreaterThan(percentages[1]); + expect(imageWidth).toBeCloseTo(500 * percentages[1] / 100 - 22, 8); + }); + + it.each(['Data', ''])('reserves space for nested table padding with "%s" cells', (value) => { + const nested = `${``.repeat(4)}
${value}
`; + const { table, percentages } = prepareTable(`
${'Description '.repeat(200)}IDStatus${nested}
`); + const nestedWidth = 515.28 * percentages[3] / 100 - 22; + const nestedDefinition = JSON.parse(table.querySelector('table')!.getAttribute('data-pdfmake')!); + nestedDefinition.widths.forEach((width: string) => { + expect(nestedWidth * Number.parseFloat(width) / 100).toBeGreaterThan(22); + }); + }); + + it('falls back to bounded equal widths without text measurement', () => { + vi.mocked(HTMLCanvasElement.prototype.getContext).mockReturnValueOnce(null); + const { percentages } = prepareTable('
IDA longer description
'); + expect(percentages).toEqual([50, 50]); + }); + + it.each([4, 8, 12])('counts %i columns in a body wider than its grouped header', (columnCount) => { + const cells = Array(columnCount / 2).fill('Value').join(''); + const html = `${cells}
Group
`; + expect(getMaxTableColumnCount(html)).toBe(columnCount); + expect(hasWideTable(html)).toBe(columnCount >= 8); + const container = document.createElement('div'); + container.innerHTML = setTableFullWidth(html); + const definition = JSON.parse(container.querySelector('table')!.getAttribute('data-pdfmake')!); + expect(definition.widths).toHaveLength(columnCount); + expect(definition.layout).toBe(columnCount >= 12 ? 'ultraCompact' : columnCount >= 8 ? 'compact' : 'default'); + }); + it('uses compact layout and smaller font for wide tables', () => { const columns = Array.from({ length: 8 }, (_, i) => `h${i}`).join(''); const html = `${columns}${Array.from({ length: 8 }, () => '').join('')}
v
`; @@ -11,8 +114,7 @@ describe('Utils: setTableFullWidth', () => { container.innerHTML = result; const pdfMakeAttr = container.querySelector('table')?.getAttribute('data-pdfmake') ?? ''; - expect(pdfMakeAttr).toContain("'layout':'compact'"); - expect(pdfMakeAttr).toContain("'fontSize':9"); + expect(JSON.parse(pdfMakeAttr)).toMatchObject({ layout: 'compact', fontSize: 9, noWrap: false }); }); it('uses default layout for regular tables', () => { @@ -24,8 +126,8 @@ describe('Utils: setTableFullWidth', () => { container.innerHTML = result; const pdfMakeAttr = container.querySelector('table')?.getAttribute('data-pdfmake') ?? ''; - expect(pdfMakeAttr).toContain("'layout':'default'"); - expect(pdfMakeAttr).not.toContain("'fontSize':9"); + expect(JSON.parse(pdfMakeAttr)).toMatchObject({ layout: 'default' }); + expect(JSON.parse(pdfMakeAttr)).not.toHaveProperty('fontSize'); }); it('detects wide tables', () => { @@ -46,8 +148,7 @@ describe('Utils: setTableFullWidth', () => { container.innerHTML = result; const pdfMakeAttr = container.querySelector('table')?.getAttribute('data-pdfmake') ?? ''; - expect(pdfMakeAttr).toContain("'layout':'ultraCompact'"); - expect(pdfMakeAttr).toContain("'fontSize':8"); + expect(JSON.parse(pdfMakeAttr)).toMatchObject({ layout: 'ultraCompact', fontSize: 8, noWrap: false }); }); it('returns max number of columns among all tables', () => { @@ -57,3 +158,137 @@ describe('Utils: setTableFullWidth', () => { expect(getMaxTableColumnCount(html)).toBe(11); }); }); + +vi.mock('./pdfFonts', async (importOriginal) => { + const actual = await importOriginal(); + const localFont = { + normal: 'Roboto-Regular.ttf', + bold: 'Roboto-Medium.ttf', + italics: 'Roboto-Italic.ttf', + bolditalics: 'Roboto-MediumItalic.ttf', + }; + return { ...actual, FONTS: { Roboto: localFont, Geologica: localFont } }; +}); + +vi.mock('../../Image', () => ({ getBase64ImageFromURL: async () => '' })); + +describe('PDF table layout', () => { + it('reduces row height by allocating more width to descriptive content', async () => { + const description = 'The investigation identified suspicious activity affecting several systems across the organization. '.repeat(3); + const html = `
IDDescriptionStatus
42${description}Open

EndMarker

`; + const prepared = setTableFullWidth(html); + const equalWidthContainer = document.createElement('div'); + equalWidthContainer.innerHTML = prepared; + equalWidthContainer.querySelector('table')!.setAttribute('data-pdfmake', JSON.stringify({ + layout: 'default', widths: ['33.333333%', '33.333333%', '33.333333%'], + })); + pdfMake.addVirtualFileSystem(fonts); + pdfMake.setTableLayouts(defaultTableLayout); + const bottomPositions: number[] = []; + for (const content of [equalWidthContainer.innerHTML, prepared]) { + const pdf = pdfMake.createPdf({ content: htmlToPdfmake(content), pageMargins: [40, 40, 40, 40] }); + const document = await getDocument({ data: new Uint8Array(await pdf.getBuffer()) }).promise; + try { + expect(document.numPages).toBe(1); + const page = await document.getPage(1); + const text = (await page.getTextContent()).items.filter((item) => 'str' in item); + expect(text.map((item) => item.str).join('').replace(/\s/g, '')).toContain(description.replace(/\s/g, '')); + bottomPositions.push(text.find((item) => item.str === 'EndMarker')!.transform[5]); + } finally { + await document.destroy(); + } + } + expect(bottomPositions[1]).toBeGreaterThan(bottomPositions[0] + 30); + }); + + const hash = '0123456789abcdef'.repeat(4); + const url = `https://example.com/${hash}`; + const imageData = readFileSync('src/static/images/logo_text_white.png'); + const cases = [ + { name: 'prose', value: 'Normal words remain readable' }, + { name: 'hash', value: hash }, + { name: 'URL', value: url }, + ]; + + const exportCases = ['table helper', 'HTML', 'Markdown', 'Fintel'].flatMap((exportType) => ( + [{ exportType, spanningHeader: false }, { exportType, spanningHeader: true }] + )).flatMap((exportCase) => [4, 8, 12].map((columnCount) => ({ ...exportCase, columnCount }))) + .flatMap((exportCase) => (exportCase.columnCount === 4 ? ['width only', 'height attribute', 'inline height'] : ['width only']) + .map((imageSize) => ({ ...exportCase, imageSize }))); + + it.each(exportCases)('keeps complete text within margins for $exportType exports ($columnCount columns, merged header: $spanningHeader, $imageSize)', async ({ exportType, spanningHeader, columnCount, imageSize }) => { + pdfMake.addVirtualFileSystem(fonts); + pdfMake.setTableLayouts(defaultTableLayout); + const imageHeight = imageSize === 'height attribute' ? 'height="300"' : imageSize === 'inline height' ? 'style="height: 300px"' : ''; + const image = ``; + const extraHeaders = Array(columnCount - 4).fill('Extra'); + const extraCells = Array(columnCount - 4).fill('Data'); + const headers = ['Name', 'Value', 'Description', ...extraHeaders, 'Status']; + const heading = spanningHeader ? `Merged header` : ''; + const rows = cases.map(({ name, value }) => [name, value === url ? `${value}` : value, 'Normal words remain readable', ...extraCells, 'Last column']); + rows.push(['Image', '', '', ...extraCells, image]); + const html = `${heading}${headers.map((header) => ``).join('')}${rows.map((row) => `${row.map((cell) => ``).join('')}`).join('')}
${header}
${cell}
`; + const markdown = spanningHeader + ? `# Table\n\n${html}` + : `| ${headers.join(' | ')} |\n| ${Array(columnCount).fill('---').join(' | ')} |\n${rows.map((row) => `| ${row.join(' | ')} |`).join('\n')}`; + const pdf = exportType === 'table helper' + ? pdfMake.createPdf({ content: htmlToPdfmake(setTableFullWidth(html)), pageMargins: [40, 40, 40, 40] }) + : exportType === 'Fintel' + ? await htmlToPdfReport('Report', html, 'Template', [], null, { includeCoverPage: false, includeBackPage: false }) + : htmlToPdf(exportType === 'Markdown' ? 'report.md' : 'report.html', exportType === 'Markdown' ? markdown : html); + const buffer = await pdf.getBuffer(); + const document = await getDocument({ data: new Uint8Array(buffer) }).promise; + try { + expect(document.numPages).toBe(1); + const page = await document.getPage(1); + const isWideFintel = exportType === 'Fintel' && columnCount >= 8; + const isVeryWideFintel = exportType === 'Fintel' && columnCount >= 12; + const pageWidth = isVeryWideFintel ? 1190.55 : isWideFintel ? 841.89 : 595.28; + const pageHeight = isVeryWideFintel ? 841.89 : isWideFintel ? 595.28 : 841.89; + expect(page.getViewport({ scale: 1 }).width).toBeCloseTo(pageWidth, 2); + expect(page.getViewport({ scale: 1 }).height).toBeCloseTo(pageHeight, 2); + const content = await page.getTextContent(); + const textItems = content.items.filter((item) => 'str' in item); + expect(textItems.map((item) => item.str).join(' ').replace(/\s+/g, ' ')).toContain('Last column'); + const compactText = textItems.map((item) => item.str).join('').replace(/\s+/g, ''); + for (const { value } of cases) { + expect(compactText).toContain(value.replace(/\s+/g, '')); + } + expect(textItems.some((item) => item.str.includes('Normal'))).toBe(true); + const margin = isVeryWideFintel ? 8 : isWideFintel ? 10 : exportType === 'Fintel' ? 20 : 40; + const preparedTable = window.document.createElement('div'); + preparedTable.innerHTML = setTableFullWidth(html, pageWidth - 2 * margin); + const definition = JSON.parse(preparedTable.querySelector('table')!.getAttribute('data-pdfmake')!); + const imageColumnWidth = Number.parseFloat(definition.widths[columnCount - 1]) / 100 * (pageWidth - 2 * margin); + const padding = columnCount >= 12 ? 1 : columnCount >= 8 ? 2 : 10; + for (const item of textItems) { + expect(item.transform[4]).toBeGreaterThanOrEqual(margin - 0.01); + expect(item.transform[4] + item.width).toBeLessThanOrEqual(pageWidth - margin + 0.01); + } + const expectedFontSize = columnCount >= 12 ? 8 : columnCount >= 8 ? 9 : 12; + expect(textItems.find((item) => item.str === 'Image')?.height).toBeCloseTo(expectedFontSize, 2); + const annotations = await page.getAnnotations(); + expect(annotations.some((annotation) => annotation.url === url)).toBe(true); + const operators = await page.getOperatorList(); + const transforms: number[][] = []; + let transform = [1, 0, 0, 1, 0, 0]; + let imageCount = 0; + operators.fnArray.forEach((operation, index) => { + if (operation === OPS.save) transforms.push([...transform]); + else if (operation === OPS.restore) transform = transforms.pop()!; + else if (operation === OPS.transform) transform = Util.transform(transform, operators.argsArray[index]); + else if (operation === OPS.paintImageXObject) { + imageCount += 1; + expect(transform[4]).toBeGreaterThanOrEqual(margin); + expect(transform[4] + transform[0]).toBeLessThanOrEqual(pageWidth - margin + 0.01); + expect(transform[0]).toBeCloseTo(Math.min(225, imageColumnWidth - 2 * padding - 2), 2); + expect(transform[4]).toBeGreaterThanOrEqual(pageWidth - margin - imageColumnWidth); + expect(Math.abs(transform[0] / transform[3])).toBeCloseTo(imageData.readUInt32BE(16) / imageData.readUInt32BE(20), 2); + } + }); + expect(imageCount).toBe(1); + } finally { + await document.destroy(); + } + }); +}); diff --git a/opencti-platform/opencti-front/src/utils/htmlToPdf/utils/pdfTableWidth.ts b/opencti-platform/opencti-front/src/utils/htmlToPdf/utils/pdfTableWidth.ts index 3d6f8909189c..1ab03f8182b7 100644 --- a/opencti-platform/opencti-front/src/utils/htmlToPdf/utils/pdfTableWidth.ts +++ b/opencti-platform/opencti-front/src/utils/htmlToPdf/utils/pdfTableWidth.ts @@ -1,14 +1,105 @@ import { CustomTableLayout } from 'pdfmake/interfaces'; +import { detectLanguage } from './pdfFonts'; export const WIDE_TABLE_COLUMN_THRESHOLD = 8; export const VERY_WIDE_TABLE_COLUMN_THRESHOLD = 12; -const getTableColumnCount = (table: Element) => { - const header = table.querySelector('thead tr'); - const body = table.querySelector('tbody tr'); - const element = header ?? body; - if (!element) return 0; - return element.querySelectorAll(header ? 'th' : 'td').length; +const TABLE_PADDING = 10; +const TABLE_BORDER = 1; + +const getTableCells = (table: HTMLTableElement) => { + const occupiedUntil: number[] = []; + return Array.from(table.rows).flatMap((row, rowIndex) => { + let column = 0; + return Array.from(row.cells).map((cell) => { + while (occupiedUntil[column] > rowIndex) column += 1; + const position = { cell, column }; + const remainingRows = (row.parentElement?.children.length ?? 1) - row.sectionRowIndex; + const rowSpan = cell.rowSpan === 0 ? remainingRows : Math.min(cell.rowSpan, remainingRows); + for (let offset = 0; offset < cell.colSpan; offset += 1) { + occupiedUntil[column + offset] = rowIndex + rowSpan; + } + column += cell.colSpan; + return position; + }); + }); +}; + +const getTableColumnCount = (table: HTMLTableElement) => getTableCells(table) + .reduce((maxColumns, { cell, column }) => Math.max(maxColumns, column + cell.colSpan), 0); + +const getColumnMinimumWidths = (table: HTMLTableElement): number[] => { + const columnCount = getTableColumnCount(table); + const padding = columnCount >= VERY_WIDE_TABLE_COLUMN_THRESHOLD ? 1 : columnCount >= WIDE_TABLE_COLUMN_THRESHOLD ? 2 : TABLE_PADDING; + const fontSize = columnCount >= VERY_WIDE_TABLE_COLUMN_THRESHOLD ? 8 : columnCount >= WIDE_TABLE_COLUMN_THRESHOLD ? 9 : 12; + const spacing = 2 * padding + 2 * TABLE_BORDER; + const minimums = Array(columnCount).fill(spacing + fontSize); + getTableCells(table).forEach(({ cell, column }) => { + const nestedTables = Array.from(cell.querySelectorAll('table')).filter((nested) => nested.parentElement?.closest('table') === table); + nestedTables.forEach((nested) => { + const nestedWidth = getColumnMinimumWidths(nested).reduce((total, width) => total + width, 0); + for (let offset = 0; offset < cell.colSpan; offset += 1) { + minimums[column + offset] = Math.max(minimums[column + offset], (nestedWidth + spacing) / cell.colSpan); + } + }); + }); + return minimums; +}; + +const allocateColumnWidths = ( + table: HTMLTableElement, + columnCount: number, + tableWidth: number, + padding: number, + fontSize: number, + fontFamily: string, + context: CanvasRenderingContext2D | null, +) => { + const equalWidth = tableWidth / columnCount; + if (!context) return Array(columnCount).fill(equalWidth); + + const spacing = 2 * padding + 2 * TABLE_BORDER; + const minimum = Math.min(equalWidth / 2, spacing + 4 * fontSize); + const maximum = tableWidth * Math.max(1 / columnCount, Math.min(0.6, 2 / columnCount)); + const desired = Array(columnCount).fill(minimum); + getTableCells(table).forEach(({ cell, column }) => { + context.font = `${cell.tagName === 'TH' ? 'bold ' : ''}${fontSize}pt ${fontFamily}`; + const text = (cell.textContent ?? '').replace(/\s+/g, ' ').trim(); + const textWidth = context.measureText(text).width * 0.75; + const demand = (textWidth + spacing) / cell.colSpan; + for (let offset = 0; offset < cell.colSpan; offset += 1) { + desired[column + offset] = Math.max(desired[column + offset], demand); + } + }); + + const widths = getColumnMinimumWidths(table).map((width) => Math.max(minimum, width)); + let remaining = tableWidth - widths.reduce((total, width) => total + width, 0); + if (remaining < 0) return Array(columnCount).fill(equalWidth); + const maximums = widths.map((width) => Math.max(maximum, width)); + while (remaining > 0.01) { + const needs = desired.map((width, column) => ( + widths[column] < maximums[column] - 0.01 ? Math.max(0, width - widths[column]) : 0 + )); + const totalWeight = needs.reduce((total, need) => total + Math.sqrt(need), 0); + if (totalWeight === 0) break; + const additions = needs.map((need, column) => Math.min(need, maximums[column] - widths[column], remaining * Math.sqrt(need) / totalWeight)); + additions.forEach((addition, column) => { + widths[column] += addition; + }); + remaining -= additions.reduce((total, addition) => total + addition, 0); + } + while (remaining > 0.01) { + const availableColumns = widths.filter((width, column) => width < maximums[column] - 0.01).length; + if (!availableColumns) break; + const share = remaining / availableColumns; + widths.forEach((width, column) => { + const addition = Math.min(share, Math.max(0, maximums[column] - width)); + widths[column] += addition; + remaining -= addition; + }); + } + const total = widths.reduce((sum, width) => sum + width, 0); + return widths.map((width) => width * tableWidth / total); }; /** @@ -17,9 +108,12 @@ const getTableColumnCount = (table: Element) => { * @param content The html content in string. * @returns Same content but with new attribute on tables. */ -const setTableFullWidth = (content: string) => { +const setTableFullWidth = (content: string, contentWidth = 515.28) => { const container = document.createElement('div'); container.innerHTML = content; + const cellWidths = new WeakMap(); + const context = container.querySelector('table') ? document.createElement('canvas').getContext('2d') : null; + const fontFamily = detectLanguage(content); container.querySelectorAll('table').forEach((table) => { const nbColumns = getTableColumnCount(table); if (nbColumns) { @@ -34,12 +128,24 @@ const setTableFullWidth = (content: string) => { layout = 'compact'; fontSize = 9; } - const noWrap = isWideTable ? ', \'noWrap\':false' : ''; - const computedFontSize = typeof fontSize === 'number' ? `, 'fontSize':${fontSize}` : ''; - table.setAttribute( - 'data-pdfmake', - `{'layout':'${layout}', 'widths':[${Array(nbColumns).fill("'*'").join()}]${computedFontSize}${noWrap}}`, - ); + if (fontSize !== undefined) table.style.fontSize = `${fontSize}pt`; + const padding = isVeryWideTable ? 1 : isWideTable ? 2 : TABLE_PADDING; + const parentCell = table.parentElement?.closest('td, th'); + const tableWidth = (parentCell && cellWidths.get(parentCell)) || contentWidth; + const widths = allocateColumnWidths(table, nbColumns, tableWidth, padding, fontSize ?? 12, fontFamily, context); + table.setAttribute('data-pdfmake', JSON.stringify({ + layout, + widths: widths.map((width) => `${100 * width / tableWidth}%`), + ...(isWideTable ? { fontSize, noWrap: false } : {}), + })); + getTableCells(table).forEach(({ cell, column }) => { + const allocatedWidth = widths.slice(column, column + cell.colSpan).reduce((total, width) => total + width, 0); + const cellWidth = Math.max(1, allocatedWidth - 2 * padding - 2 * TABLE_BORDER); + cellWidths.set(cell, cellWidth); + cell.querySelectorAll('img').forEach((image) => { + if (image.closest('td, th') === cell) image.style.maxWidth = `${cellWidth}pt`; + }); + }); } }); return container.innerHTML; @@ -65,7 +171,7 @@ const commonTableLayout: Omit< hLineColor: '#dcdde4', vLineColor: '#dcdde4', hLineWidth: () => 1, - vLineWidth: (i, { table }) => ((i === 0 || i === (table.widths ?? []).length) ? 1 : 0), + vLineWidth: (i, { table }) => ((i === 0 || i === (table.widths ?? []).length) ? TABLE_BORDER : 0), }; const createTableLayout = (padding: number): CustomTableLayout => ({ @@ -79,8 +185,8 @@ const createTableLayout = (padding: number): CustomTableLayout => ({ export const defaultTableLayout: { [p: string]: CustomTableLayout } = { default: { ...createTableLayout(4), - paddingLeft: () => 10, - paddingRight: () => 10, + paddingLeft: () => TABLE_PADDING, + paddingRight: () => TABLE_PADDING, }, compact: createTableLayout(2), ultraCompact: createTableLayout(1), diff --git a/opencti-platform/opencti-front/yarn.lock b/opencti-platform/opencti-front/yarn.lock index 090fb7846b65..d65e4790798e 100644 --- a/opencti-platform/opencti-front/yarn.lock +++ b/opencti-platform/opencti-front/yarn.lock @@ -11987,6 +11987,7 @@ __metadata: mdi-material-ui: "npm:7.9.4" moment: "npm:2.30.1" monocart-reporter: "npm:2.13.1" + pdfjs-dist: "npm:5.4.296" pdfmake: "npm:0.3.11" pmtiles: "npm:4.5.0" prop-types: "npm:15.8.1"