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('');
+ expect(percentages[0]).toBeGreaterThan(percentages[1]);
+ });
+
+ it.each([1, 2, 4, 8, 12])('keeps balanced content balanced across %i columns', (columnCount) => {
+ const { percentages } = prepareTable(`${'| Equal content | '.repeat(columnCount)}
`);
+ 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(`| ${'abcdef0123456789'.repeat(200)} | ${'ID | '.repeat(columnCount - 1)}
`);
+ 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 = ``;
+ const { table, percentages } = prepareTable(`| ${'Description '.repeat(200)} | ID | Status | ${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('');
+ 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 = ``;
+ 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 }, () => '| v | ').join('')}
`;
@@ -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 = `| ID | Description | Status |
|---|
| 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) => `| ${header} | `).join('')}
${rows.map((row) => `${row.map((cell) => `| ${cell} | `).join('')}
`).join('')}
`;
+ 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"