// FILIUS' report against Java's own (tools/java-oracle's ReportRun, // report.java.json): every piece of text on Java's pages — its words, place, // font, size and colour — laid out here from the same project: the title, // the devices' pages of Internet_Komplett (real MAC addresses, no run), the // data exchange of einfaches_rechnernetz_komplett after a run of 3 s (its // rows taken from Java's pages, so that the layout is compared, not the // run), a project without devices and an empty one. import { createHash } from 'node:crypto'; import { readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { PDFDocument, StandardFonts } from 'pdf-lib'; import { beforeAll, describe, expect, it } from 'vitest'; import * as Y from 'yjs'; import { openProjectArchive } from '@mdg-filius/fls'; import { reportDevices, writeProject, type ReportDevice } from '@mdg-filius/doc'; import { translate } from '../src/i18n/i18n.js'; import { reportPages, reportTitle, SMALL_FONT, TRAFFIC_WIDTHS, type ReportInput } from '../src/report/report.js'; import { pdfFonts, REPORT_CREATOR, reportPdf } from '../src/report/reportPdf.js'; import { ReportWriter, type ReportFonts, type ReportOp } from '../src/report/reportWriter.js'; const here = dirname(fileURLToPath(import.meta.url)); interface JavaPdf { readonly title: string; readonly creator: string; readonly pages: readonly { readonly size: readonly number[]; readonly texts: readonly string[]; readonly images: readonly { x: number; y: number; width: number; height: number; pixels: readonly number[] }[]; }[]; } const java = JSON.parse(readFileSync(join(here, 'report.java.json'), 'utf8')) as { readonly projects: Readonly>; readonly empty: { readonly pdf: JavaPdf }; }; const t = (key: string) => translate('de', key); let fonts: ReportFonts; beforeAll(async () => { const pdf = await PDFDocument.create(); fonts = pdfFonts({ Helvetica: await pdf.embedFont(StandardFonts.Helvetica), 'Helvetica-Bold': await pdf.embedFont(StandardFonts.HelveticaBold) }); }); function devicesOf(file: string): ReportDevice[] { const doc = new Y.Doc(); let next = 0; writeProject( doc, openProjectArchive(new Uint8Array(readFileSync(join(here, '../../../corpus/java-examples', file))), () => `id${next++}`), ); return reportDevices(doc); } /** A piece of text as Java's file has it: x|y|font|size|colour|text, the text without the spaces at its end. */ const javaText = (piece: string) => { const [x, y, font, size, color, ...words] = piece.split('|'); return `${Number(x).toFixed(2)}|${Number(y).toFixed(2)}|${font}|${Number(size)}|${color}|${words.join('|').trimEnd()}`; }; const ourText = (op: ReportOp) => (op.kind === 'text' ? `${op.x.toFixed(2)}|${op.y.toFixed(2)}|${op.font}|${op.size}|${op.color.join(',')}|${op.text.trimEnd()}` : undefined); /** From the top down, left to right: iText writes paragraphs and tables in layers of their own, so the order of drawing differs. */ const byPlace = (pieces: readonly string[]) => [...pieces].sort((lhs, rhs) => { const [lx, ly] = lhs.split('|').map(Number); const [rx, ry] = rhs.split('|').map(Number); return ry! - ly! || lx! - rx! || (lhs < rhs ? -1 : 1); }); const texts = (ops: readonly ReportOp[]) => byPlace(ops.map(ourText).filter((text): text is string => text !== undefined)); const javaTexts = (pieces: readonly string[]) => byPlace(pieces.map(javaText)); describe("the report's devices, as Java's report", () => { const file = 'Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls'; it('writes every device of Internet_Komplett as Java does, piece by piece', () => { const javaPdf = java.projects[file]!.pdf; const pages = reportPages( { projectName: file, image: { width: javaPdf.pages[0]!.images[0]!.pixels[0]!, height: javaPdf.pages[0]!.images[0]!.pixels[1]! }, devices: devicesOf(file), traffic: [], t }, fonts, ); expect(pages.length).toBe(javaPdf.pages.length); // Java's "true" and "false" are Java's own „Ja“ and „Nein“ here. const ours = (index: number) => texts(pages[index]!).map((text) => text.replace(/\|Ja$/, '|true').replace(/\|Nein$/, '|false')); for (let index = 1; index < pages.length; index++) expect(ours(index), `page ${index + 1}`).toEqual(javaTexts(javaPdf.pages[index]!.texts)); }); }); describe("the report's first page, as Java's", () => { const image = (pdf: JavaPdf) => ({ width: pdf.pages[0]!.images[0]!.pixels[0]!, height: pdf.pages[0]!.images[0]!.pixels[1]! }); const imageOps = (ops: readonly ReportOp[]) => ops.filter((op) => op.kind === 'image').map((op) => ({ x: op.x, y: Math.round(op.y * 100) / 100, width: op.width, height: Math.round(op.height * 100) / 100 })); const javaImages = (pdf: JavaPdf, page: number) => pdf.pages[page]!.images.map(({ x, y, width, height }) => ({ x, y, width, height })); it('writes the title, the name below it and the overview with the image as Java does', () => { const file = 'einfaches_rechnernetz_komplett.fls'; const javaPdf = java.projects[file]!.pdf; const pages = reportPages({ projectName: file, image: image(javaPdf), devices: [], traffic: [], t }, fonts); // Below the title Java writes the file's path, here its name: the same place, font and size. const withoutPath = (pieces: readonly string[]) => pieces.map((piece) => (piece.includes('|Helvetica|8|') ? piece.replace(/\|[^|]*$/, '|') : piece)); expect(withoutPath(texts(pages[0]!))).toEqual(withoutPath(javaTexts(javaPdf.pages[0]!.texts))); expect(texts(pages[0]!)).toContain('36.00|752.00|Helvetica|8|0,0,0|einfaches_rechnernetz_komplett.fls'); expect(imageOps(pages[0]!)).toEqual(javaImages(javaPdf, 0)); expect(javaPdf.title).toBe(reportTitle(t, file)); expect(javaPdf.pages.every((page) => page.size.join() === '595,842')).toBe(true); }); it('breaks a long title as Java does, centred', () => { const file = 'Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls'; const titleOf = (pieces: readonly string[]) => pieces.filter((piece) => piece.includes('|Helvetica-Bold|18|')); const pages = reportPages({ projectName: file, image: image(java.projects[file]!.pdf), devices: [], traffic: [], t }, fonts); expect(titleOf(texts(pages[0]!))).toEqual(titleOf(javaTexts(java.projects[file]!.pdf.pages[0]!.texts))); }); it('writes a project without devices as Java does: the overview, then „Komponenten“ alone', () => { const javaPdf = java.projects['documentation.fls']!.pdf; const pages = reportPages({ projectName: 'documentation.fls', image: image(javaPdf), devices: [], traffic: [], t }, fonts); expect(pages.length).toBe(2); expect(imageOps(pages[0]!)).toEqual(javaImages(javaPdf, 0)); expect(texts(pages[1]!)).toEqual(javaTexts(javaPdf.pages[1]!.texts)); }); it("writes an empty project's report as Java does, but its image fits on its page (Java's runs beyond it)", () => { const javaPdf = java.empty.pdf; const pixels = { width: javaPdf.pages[1]!.images[0]!.pixels[0]!, height: javaPdf.pages[1]!.images[0]!.pixels[1]! }; const pages = reportPages({ image: pixels, devices: [], traffic: [], t }, fonts); expect(pages.length).toBe(javaPdf.pages.length); [0, 2].forEach((page) => expect(texts(pages[page]!)).toEqual(javaTexts(javaPdf.pages[page]!.texts))); // Java's: as wide as the page, 915 high, from 795.5 down to -119.75; here as high as the page leaves. expect(javaImages(javaPdf, 1)).toEqual([{ x: 36, y: -119.75, width: 523, height: 915.25 }]); expect(imageOps(pages[1]!)).toEqual([{ x: 36, y: 36, width: Math.round(((759.5 * 20) / 35) * 1e13) / 1e13, height: 759.5 }]); expect(javaPdf.title).toBe(reportTitle(t, undefined)); }); }); /** Java's data exchange, taken back from its pages: each card's heading and its rows' cells (by their columns). */ function javaTraffic(pdf: JavaPdf): { title: string; header: string[]; rows: string[][] }[] { const columns = [44.5, 64.5, 124.5, 204.5, 284.5, 324.5, 374.5]; const cards: { title: string; header: string[]; rows: string[][] }[] = []; let inTraffic = false; for (const page of pdf.pages) { const lines = new Map(); for (const piece of page.texts) { const [x, y, font, size, , ...words] = piece.split('|'); lines.set(Number(y), [...(lines.get(Number(y)) ?? []), { x: Number(x), font: font!, size: Number(size), text: words.join('|').trimEnd() }]); } for (const y of [...lines.keys()].sort((lhs, rhs) => rhs - lhs)) { const line = lines.get(y)!.sort((lhs, rhs) => lhs.x - rhs.x); if (line[0]!.size === 14) inTraffic = line[1]?.text === t('report.traffic'); if (!inTraffic || line[0]!.size === 14) continue; if (line[0]!.size === 12) { cards.push({ title: line[1]!.text, header: [], rows: [] }); continue; } const card = cards[cards.length - 1]!; if (line[0]!.font === 'Helvetica-Bold') { card.header = line.map((each) => each.text); continue; } if (line[0]!.x === columns[0]) { const row = columns.map(() => ''); for (const each of line) row[columns.indexOf(each.x)] = each.text; card.rows.push(row); } else { // A comment's further line. const row = card.rows[card.rows.length - 1]!; row[6] = `${row[6]}\n${line.map((each) => each.text).join(' ')}`; } } } return cards; } describe("the report's data exchange, as Java's", () => { it("lays out Java's rows of each card as Java does: small, the columns fixed and centred, across pages", () => { const file = 'einfaches_rechnernetz_komplett.fls'; const javaPdf = java.projects[file]!.pdf; const traffic = javaTraffic(javaPdf); // Every card the Lauscher had, with all its rows. expect(traffic.map((card) => card.rows.length)).toEqual(java.projects[file]!.packetLogCards.map((card) => card.rows)); expect(traffic[0]!.header).toEqual(['number', 'time', 'source', 'destination', 'protocol', 'layer', 'comment'].map((key) => t(`capture.${key}`))); const pages = reportPages({ projectName: file, image: { width: 100, height: 50 }, devices: [], traffic, t }, fonts); const firstJava = javaPdf.pages.findIndex((page) => page.texts.some((piece) => piece.endsWith(`|${t('report.traffic')}`))); const firstOurs = pages.findIndex((page) => texts(page).some((piece) => piece.endsWith(`|${t('report.traffic')}`))); expect(pages.length - firstOurs).toBe(javaPdf.pages.length - firstJava); for (let index = 0; firstOurs + index < pages.length; index++) { expect(texts(pages[firstOurs + index]!), `page ${index + 1} of the data exchange`).toEqual(javaTexts(javaPdf.pages[firstJava + index]!.texts)); } expect(TRAFFIC_WIDTHS.reduce((sum, width) => sum + width, 0)).toBe(510); }); it('marks the header and every second row as Java does', () => { const pages = reportPages( { image: { width: 100, height: 50 }, devices: [], traffic: [ { title: 'A - 1.2.3.4', header: ['N', 'Z', 'Q', 'D', 'P', 'S', 'B'], rows: [ ['1', '', '', '', '', '', ''], ['2', '', '', '', '', '', ''], ['3', '', '', '', '', '', ''], ], }, ], t, }, fonts, ); const rects = pages.at(-1)!.filter((op) => op.kind === 'rect'); // The header's seven cells light blue, the second row's grey; each cell its column wide, its row high. expect(rects.map((op) => (op.kind === 'rect' ? op.color.join() : ''))).toEqual([...Array(7).fill('230,240,255'), ...Array(7).fill('240,240,240')]); expect(rects.map((op) => (op.kind === 'rect' ? op.width : 0))).toEqual([...TRAFFIC_WIDTHS, ...TRAFFIC_WIDTHS]); expect(rects[0]).toMatchObject({ x: 42.5, height: 12 }); }); }); describe('the report as a PDF', () => { const file = 'Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls'; const input = (): ReportInput & { png: Uint8Array } => ({ projectName: file, png: new Uint8Array(readFileSync(join(here, '../src/assets/documentation/add_small.png'))), image: { width: 0, height: 0 }, devices: devicesOf(file), // A character Helvetica cannot write ("→") becomes "?". traffic: [{ title: 'Heimrouter - 192.168.1.1', header: ['N', 'Z', 'Q', 'D', 'P', 'S', 'B'], rows: [['1', '12:00:00.000', 'a → b', 'Ü', 'TCP', 'Transport', 'eins\nzwei']] }], t, }); const date = new Date(Date.UTC(2026, 9, 4, 12, 0, 0)); it("is a PDF of A4 pages with Java's title and creator", async () => { const bytes = await reportPdf(input(), date); const loaded = await PDFDocument.load(bytes); expect(loaded.getTitle()).toBe(`Filius-Dokumentation: ${file.replace('.fls', '')}`); expect(loaded.getCreator()).toBe(REPORT_CREATOR); expect(loaded.getPages().map((page) => page.getSize())).toEqual(Array(12).fill({ width: 595, height: 842 })); }); it('is read by Java’s own PDF reader (iText) with the same text in the same places', async () => { const bytes = await reportPdf(input(), date); const digest = createHash('sha256').update(bytes).digest('hex'); if (process.env.UPDATE_REPORT === '1') writeFileSync(join(here, '../../../tools/java-oracle/.build/report-ours.pdf'), bytes); const read = (JSON.parse(readFileSync(join(here, 'report.ours.java.json'), 'utf8')) as Record)['report-ours.pdf']!; // Java read this very file (UPDATE_REPORT=1, then ReportRun --read). expect(read.sha256).toBe(digest); const pages = reportPages({ ...input(), image: { width: 100, height: 100 } }, fonts); expect(read.pages.length).toBe(pages.length); pages.forEach((page, index) => expect(texts(page), `page ${index + 1}`).toEqual(javaTexts(read.pages[index]!.texts))); expect(read.pages[0]!.images).toEqual([{ x: 36, y: 143.5, width: 523, height: 523, pixels: [40, 40] }]); expect(texts(pages.at(-1)!)).toContain('124.50|745.00|Helvetica|8|0,0,0|a ? b'); expect([read.title, read.creator]).toEqual([`Filius-Dokumentation: ${file.replace('.fls', '')}`, REPORT_CREATOR]); }); }); describe("the report's layout rules", () => { /** Every character 10 wide at size 10. */ const even: ReportFonts = { width: (text, _font, size) => text.length * size, clean: (text) => text }; const style = { font: 'Helvetica', size: 10, color: [0, 0, 0] } as const; it("breaks after a hyphen as after a space, as Java's path below a long title", () => { const javaPdf = java.projects['Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls']!.pdf; const javaPath = javaTexts(javaPdf.pages[0]!.texts).filter((piece) => piece.includes('|Helvetica|8|')); const writer = new ReportWriter(fonts); writer.paragraph( [{ text: reportTitle(t, 'Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls'), style: { font: 'Helvetica-Bold', size: 18, color: [30, 50, 120] } }], 'center', ); writer.chunk(javaPath.map((piece) => piece.split('|').slice(5).join('|')).join(''), SMALL_FONT); writer.newline(); expect(texts(writer.finish()[0]!).filter((piece) => piece.includes('|Helvetica|8|'))).toEqual(javaPath); expect(javaPath[0]).toMatch(/java-$/); }); it('lets a space at the end of a line take no room', () => { const writer = new ReportWriter(even); writer.table({ widths: [104], fixed: true, align: 'left', rows: [[{ text: 'ab cdefghi j', style }]] }); // "ab cdefghi" fills the 100 inside the cell; the space after it does not push "cdefghi" to the next line. expect(texts(writer.finish()[0]!).map((piece) => piece.split('|').at(-1))).toEqual(['ab cdefghi', 'j']); }); it('holds a character a line where the cell is narrower than one', () => { const writer = new ReportWriter(even); writer.table({ widths: [8], fixed: true, align: 'left', rows: [[{ text: 'ab', style }]] }); const ops = writer.finish()[0]!; expect(texts(ops).map((piece) => piece.split('|').slice(1, 2).concat(piece.split('|').slice(-1)))).toEqual([ ['794.00', 'a'], ['784.00', 'b'], ]); }); it('draws nothing for a cell of spaces, and gives it a line', () => { const writer = new ReportWriter(even); writer.table({ widths: [50, 50], fixed: true, align: 'left', rows: [ [ { text: ' ', style }, { text: '', style }, ], ], }); writer.paragraph([{ text: 'danach', style }]); expect(texts(writer.finish()[0]!)).toEqual(['36.00|777.00|Helvetica|10|0,0,0|danach']); }); it('begins the next page with a line that would end below the margin of 36', () => { const rows = (count: number) => Array.from({ length: count }, () => [{ text: 'x', style: SMALL_FONT }]); const at = (count: number) => { const writer = new ReportWriter(even); // Rows 12 high from 806 down, then a paragraph's line 15 below. writer.table({ widths: [20], fixed: true, align: 'left', rows: rows(count) }); writer.paragraph([{ text: 'Zeile', style }]); const pages = writer.finish(); return [pages.length, texts(pages.at(-1)!).at(-1)!.split('|')[1]]; }; // 806 - 62 × 12 = 62: the line at 47; 806 - 63 × 12 = 50: at 35 it would end below 36. expect(at(62)).toEqual([1, '47.00']); expect(at(63)).toEqual([2, '791.00']); }); it('writes a tab as a space and a character Helvetica has not got as "?"', () => { expect(fonts.clean('a\tb → ü€')).toBe('a b ? ü€'); }); });