feat: F4.8d the documentation mode's report as a PDF, as Java's

ReportRun reads Java's report back with iText: every piece of text with its
place, font, size and colour. The editor lays the report out by iText's
rules (A4, leading 1.5, loose chunks in the current line, tables 80 % wide
with padding 2, fixed traffic columns, page breaks) and draws it with
pdf-lib in the PDF's own Helvetica, widths without kerning as iText measures.
All device pages of Internet_Komplett, the first page and the data exchange
of einfaches_rechnernetz_komplett match Java piece by piece; Java's iText
reads our PDF with the same text in the same places. The sidebar gains
Java's "Report"; the frames come from the room by a short subscription.
Not adopted: true/false for DHCP (Java's own Ja/Nein), hash table orders,
an image running off the page, the report writing back into the model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 12:35:35 +00:00
co-authored by Claude Opus 5.5
parent 7e68414bb2
commit b8b999fd66
29 changed files with 7726 additions and 23 deletions
+24 -1
View File
@@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest';
import { captureRows, TCP_FLAG, type CapturedFrame, type EthernetFrame } from '@mdg-filius/core';
import type { TraceFrame } from '@mdg-filius/protocol';
import { translate } from '../src/i18n/i18n.js';
import { captureWords, exportText, frameCells, frameDetails } from '../src/simulation/captureCells.js';
import { captureWords, exportText, frameCells, frameDetails, reportRows, reportTraffic } from '../src/simulation/captureCells.js';
/** A TCP segment with data from 10.0.0.1:50000 to 10.0.0.2:80, as the room sends it. */
function frame(number: number, timeMilliseconds: number): TraceFrame {
@@ -76,3 +76,26 @@ describe("the data exchange window's cells", () => {
expect(text.match(/^\+-/gm)).toHaveLength(2);
});
});
describe("the report's data exchange", () => {
it('writes every layer of a frame as a row, with addresses, as Java\'s report does', () => {
expect(reportRows([frame(3, 1_234)], clock, words('de'))).toEqual([
['3', '08:30:01.234', '02:00:00:00:00:01', '02:00:00:00:00:02', '', 'Netzzugang', '0x800'],
['3', '08:30:01.234', '10.0.0.1', '10.0.0.2', 'IP', 'Vermittlung', 'Protokoll: 6, TTL: 64'],
['3', '08:30:01.234', '10.0.0.1:50000', '10.0.0.2:80', 'TCP', 'Transport', 'SEQ: 2.000.001, ACK: 1.000.001'],
['3', '08:30:01.234', '10.0.0.1:50000', '10.0.0.2:80', 'HTTP', 'Anwendung', 'GET / HTTP/1.1\nHost: x\n'],
]);
});
it("lists each card that saw frames, under its device's label and address; one without frames not", () => {
const cards = [
{ portId: 'a', label: 'Rechner 1', ipAddress: '10.0.0.1' },
{ portId: 'b', label: 'Rechner 2', ipAddress: '10.0.0.2' },
{ portId: 'c', label: 'Rechner 3', ipAddress: '10.0.0.3' },
];
const traffic = reportTraffic(cards, new Map([['a', [frame(1, 0)]], ['b', []]]), clock, words('de'));
expect(traffic.map((card) => card.title)).toEqual(['Rechner 1 - 10.0.0.1']);
expect(traffic[0]!.header).toEqual(words('de').header);
expect(traffic[0]!.rows.length).toBe(4);
});
});
+16
View File
@@ -258,6 +258,22 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
'docu.color': 'guievents_msg13',
'docu.export': 'docusidebar_msg5',
'docu.exportHint': 'docusidebar_msg6',
'docu.report': 'docusidebar_msg7',
'docu.reportHint': 'docusidebar_msg8',
'report.title': 'report_title',
'report.overview': 'report_overview',
'report.components': 'report_component_config',
'report.baseConfig': 'report_base_config',
'report.nics': 'report_nics',
'report.apps': 'report_apps',
'report.routingTable': 'report_routing_table',
'report.traffic': 'report_network_traffic',
'report.dnsRecords': 'report_dns_rr',
'report.domain': 'report_domain',
'report.type': 'report_type',
'report.data': 'report_data',
'report.yes': 'main_dlg_YES',
'report.no': 'main_dlg_NO',
'capture.title': 'lauscherdialog_msg1',
'capture.number': 'rp_lauscher_msg1',
'capture.time': 'rp_lauscher_msg2',
+7 -7
View File
@@ -144,16 +144,16 @@ describe('the words of a text in the image', () => {
});
describe("the image's names", () => {
it("writes the project's name in the footer and names the image after it, as Java names it after its file", () => {
expect(exportNames('nat_with_mapped_port.fls')).toEqual({ footer: 'nat_with_mapped_port.fls', file: 'nat_with_mapped_port.png' });
expect(exportNames('netz.v2.fls')).toEqual({ footer: 'netz.v2.fls', file: 'netz.v2.png' });
expect(exportNames('Netz')).toEqual({ footer: 'Netz', file: 'Netz.png' });
expect(exportNames('.fls')).toEqual({ footer: '.fls', file: '.fls.png' });
it("writes the project's name in the footer and names the image and the report after it, as Java names them after its file", () => {
expect(exportNames('nat_with_mapped_port.fls')).toEqual({ footer: 'nat_with_mapped_port.fls', file: 'nat_with_mapped_port.png', report: 'nat_with_mapped_port.pdf' });
expect(exportNames('netz.v2.fls')).toEqual({ footer: 'netz.v2.fls', file: 'netz.v2.png', report: 'netz.v2.pdf' });
expect(exportNames('Netz')).toEqual({ footer: 'Netz', file: 'Netz.png', report: 'Netz.pdf' });
expect(exportNames('.fls')).toEqual({ footer: '.fls', file: '.fls.png', report: '.fls.pdf' });
});
it('writes „Filius“ without a name, as Java does without a file', () => {
expect(exportNames(undefined)).toEqual({ footer: 'Filius', file: 'Filius.png' });
expect(exportNames(' ')).toEqual({ footer: 'Filius', file: 'Filius.png' });
expect(exportNames(undefined)).toEqual({ footer: 'Filius', file: 'Filius.png', report: 'Filius.pdf' });
expect(exportNames(' ')).toEqual({ footer: 'Filius', file: 'Filius.png', report: 'Filius.pdf' });
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+326
View File
@@ -0,0 +1,326 @@
// 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<Record<string, { readonly pdf: JavaPdf; readonly packetLogCards: readonly { mac: string; rows: number }[] }>>;
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(/\|[^|]*$/, '|<name>') : 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<number, { x: number; font: string; size: number; text: string }[]>();
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<string, JavaPdf & { sha256: string }>)['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 ? ü€');
});
});