Files
houseplan-card/test/pdf-writer.test.mjs
Matysh 15b060ac38 fix: polish PDF export
Issue: #482
User-Visible: yes
2026-09-07 11:06:28 +03:00

211 lines
8.4 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs';
import {
measurePdfText, pdfTextBounds, writePdf,
} from '../test-build/pdf/pdf-writer.js';
import {
PDF_FONT_ASCENT, PDF_FONT_DESCENT, PDF_FONT_UNITS_PER_EM,
} from '../test-build/pdf/pdf-font.generated.js';
const now = new Date('2026-09-07T10:00:00.000Z');
test('PDF writer emits a deterministic parseable A4 document with extractable Cyrillic', async () => {
const page = {
width: 595.28, height: 841.89, now,
commands: [
{ kind: 'path', rings: [[[40, 40], [200, 40], [200, 160], [40, 160]]],
fill: [0.5, 0.5, 0.5], stroke: [0, 0, 0], width: 0.7 },
{ kind: 'text', x: 120, y: 100, text: 'Кухня · Scale 1:50', size: 12, align: 'center' },
],
};
const first = writePdf(page);
const second = writePdf(page);
assert.deepEqual(first, second);
assert.match(new TextDecoder('latin1').decode(first), /CIDFontType2/);
assert.match(new TextDecoder('latin1').decode(first), /ToUnicode/);
const task = getDocument({ data: first, useWorkerFetch: false, isEvalSupported: false });
const document = await task.promise;
assert.equal(document.numPages, 1);
const content = await (await document.getPage(1)).getTextContent();
assert.match(content.items.map((item) => item.str).join(''), /Кухня/);
await task.destroy();
});
test('embedded font exposes proportional text metrics', () => {
assert.ok(measurePdfText('WW', 10) > measurePdfText('ii', 10));
});
test('text bounds use the embedded ascent/descent around the emitted baseline', () => {
const command = { kind: 'text', x: 30, y: 70, text: 'Width', size: 12 };
const width = measurePdfText(command.text, command.size);
const ascent = PDF_FONT_ASCENT * command.size / PDF_FONT_UNITS_PER_EM;
const descent = PDF_FONT_DESCENT * command.size / PDF_FONT_UNITS_PER_EM;
assert.deepEqual(pdfTextBounds(command), {
minX: command.x,
minY: command.y - ascent,
maxX: command.x + width,
maxY: command.y - descent,
});
});
test('centered vertical text bounds handle both rotations about the real baseline anchor', () => {
const base = { kind: 'text', x: 42, y: 80, text: '12.34 m', size: 7, align: 'center' };
const width = measurePdfText(base.text, base.size);
const ascent = PDF_FONT_ASCENT * base.size / PDF_FONT_UNITS_PER_EM;
const descent = PDF_FONT_DESCENT * base.size / PDF_FONT_UNITS_PER_EM;
const close = (actual, expected) => assert.ok(Math.abs(actual - expected) < 1e-10,
`${actual} != ${expected}`);
const clockwise = pdfTextBounds({ ...base, angle: 90 });
close(clockwise.minX, base.x - ascent);
close(clockwise.maxX, base.x - descent);
close(clockwise.minY, base.y - width / 2);
close(clockwise.maxY, base.y + width / 2);
const counterClockwise = pdfTextBounds({ ...base, angle: -90 });
close(counterClockwise.minX, base.x + descent);
close(counterClockwise.maxX, base.x + ascent);
close(counterClockwise.minY, base.y - width / 2);
close(counterClockwise.maxY, base.y + width / 2);
});
test('emitted vertical Tm uses the same centered baseline as text bounds', () => {
const command = {
kind: 'text', x: 42, y: 80, text: '12.34 m', size: 7, angle: 90, align: 'center',
};
const width = measurePdfText(command.text, command.size);
const close = (actual, expected) => assert.ok(Math.abs(actual - expected) < 1e-10,
`${actual} != ${expected}`);
const page = { width: 200, height: 200, now, commands: [command] };
const raw = new TextDecoder('latin1').decode(writePdf(page));
const tm = raw.match(/(-?[\d.]+) (-?[\d.]+) (-?[\d.]+) (-?[\d.]+) (-?[\d.]+) (-?[\d.]+) Tm/);
assert.ok(tm, 'the text matrix is emitted');
assert.deepEqual(tm.slice(1, 5).map(Number), [0, 1, -1, 0]);
close(Number(tm[5]), command.x);
assert.ok(Math.abs(Number(tm[6]) - (page.height - command.y - width / 2)) <= 0.005,
'the serialized Tm starts at the same aligned baseline used by the bounds helper');
});
test('emitted angled right-aligned Tm rotates about the command anchor', () => {
const command = {
kind: 'text', x: 91, y: 47, text: 'Rotated label', size: 9, angle: 37, align: 'right',
};
const width = measurePdfText(command.text, command.size);
const radians = command.angle * Math.PI / 180;
const cos = Math.cos(radians), sin = Math.sin(radians);
const page = { width: 200, height: 200, now, commands: [command] };
const raw = new TextDecoder('latin1').decode(writePdf(page));
const tm = raw.match(/(-?[\d.]+) (-?[\d.]+) (-?[\d.]+) (-?[\d.]+) (-?[\d.]+) (-?[\d.]+) Tm/);
assert.ok(tm, 'the angled text matrix is emitted');
const actual = tm.slice(1).map(Number);
const expected = [
cos, sin, -sin, cos,
command.x - cos * width,
page.height - command.y - sin * width,
];
expected.forEach((value, index) => assert.ok(Math.abs(actual[index] - value) <= 0.005,
`Tm[${index}] ${actual[index]} != ${value}`));
});
test('JPEG images keep independent opacity resources', () => {
const bytes = writePdf({
width: 595.28, height: 841.89, now,
images: [
{ id: 'a', bytes: new Uint8Array([0xff, 0xd8, 0xff, 0xd9]), width: 1, height: 1 },
{ id: 'b', bytes: new Uint8Array([0xff, 0xd8, 0xff, 0xd9]), width: 1, height: 1 },
],
commands: [
{ kind: 'image', imageId: 'a', x: 20, y: 30, width: 100, height: 50, opacity: 0.25 },
{ kind: 'image', imageId: 'b', x: 200, y: 30, width: 40, height: 80, opacity: 0.6 },
],
});
const raw = new TextDecoder('latin1').decode(bytes);
assert.match(raw, /DCTDecode/);
assert.match(raw, /\/ca 0\.25/);
assert.match(raw, /\/ca 0\.6/);
});
test('path colors retain 8-bit precision without changing coordinate rounding', () => {
const bytes = writePdf({
width: 200, height: 200, now,
commands: [{
kind: 'path',
rings: [[[10.1234, 20.1234], [30.129, 20.1234], [30.129, 40.129]]],
fill: [127 / 255, 127 / 255, 127 / 255],
}],
});
const raw = new TextDecoder('latin1').decode(bytes);
assert.match(raw, /0\.498039 0\.498039 0\.498039 rg/);
assert.match(raw, /10\.12 179\.88 m\n30\.13 179\.88 l\n30\.13 159\.87 l/);
assert.doesNotMatch(raw, /10\.1234 179\.8766 m/);
});
test('hatched paths use an even-odd clip and draw their border above page-anchored hatch lines', async () => {
const page = {
width: 200, height: 200, now,
commands: [{
kind: 'path',
rings: [
[[20, 20], [180, 20], [180, 180], [20, 180]],
[[70, 70], [130, 70], [130, 130], [70, 130]],
],
fill: [127 / 255, 127 / 255, 127 / 255],
stroke: [0, 0, 0],
width: 1,
hatch: {
lines: [
[[0, 0], [200, 200]],
[[-20, 20], [180, 220]],
],
stroke: [0.2, 0.2, 0.2],
width: 0.3,
},
}],
};
const first = writePdf(page);
const second = writePdf(page);
assert.deepEqual(first, second);
const raw = new TextDecoder('latin1').decode(first);
const clipIndex = raw.indexOf('W*\nn');
const hatchIndex = raw.indexOf('0 200 m\n200 0 l', clipIndex);
const borderIndex = raw.indexOf('0 0 0 RG 1 w', hatchIndex);
assert.ok(clipIndex >= 0, 'expected an even-odd clipping path');
assert.ok(hatchIndex > clipIndex, 'expected explicit page-coordinate hatch lines after the clip');
assert.ok(borderIndex > hatchIndex, 'expected the wall border to be drawn after the hatch');
const task = getDocument({ data: first, useWorkerFetch: false, isEvalSupported: false });
const document = await task.promise;
assert.equal(document.numPages, 1);
await task.destroy();
});
test('compound vectors support deterministic even-odd fill without a stroke', async () => {
const page = {
width: 100, height: 100, now,
commands: [{
kind: 'vector',
ops: [
{ op: 'M', x: 10, y: 10 }, { op: 'L', x: 90, y: 10 },
{ op: 'L', x: 90, y: 90 }, { op: 'L', x: 10, y: 90 }, { op: 'Z' },
{ op: 'M', x: 30, y: 30 }, { op: 'L', x: 70, y: 30 },
{ op: 'L', x: 70, y: 70 }, { op: 'L', x: 30, y: 70 }, { op: 'Z' },
],
fill: [0.1, 0.2, 0.3],
width: 0,
fillRule: 'evenodd',
}],
};
const bytes = writePdf(page);
const raw = new TextDecoder('latin1').decode(bytes);
assert.match(raw, /0\.1 0\.2 0\.3 rg[\s\S]*\nf\*\nQ/);
assert.doesNotMatch(raw, /0\.1 0\.2 0\.3 RG/);
const task = getDocument({ data: bytes, useWorkerFetch: false, isEvalSupported: false });
const document = await task.promise;
assert.equal(document.numPages, 1);
await task.destroy();
});