feat: export current space as architectural PDF

Issue: #53
User-Visible: yes
This commit is contained in:
Matysh
2026-09-07 03:47:57 +03:00
parent 1c59e16514
commit d277eeb73b
87 changed files with 3965 additions and 849 deletions
+15 -1
View File
@@ -162,8 +162,14 @@ export const LOW_HEADROOM_WARNING_BYTES = 15_000;
* бюджета. Удаление persisted room-draft веток снизило initial факт до
* 288 571 Б: 929 Б сверху и 1 071 Б до нижней границы полосы. Экономия
* закреплена и не превращается в разрешение для незаметного роста.
*
* 2026-09-07, #53: потолок перецентрирован 289 500 → 290 500 без изменения
* общего бюджета. Кнопка PDF и exact-build loader подняли initial факт до
* 289 556 Б; writer, шрифт, печатная сцена и общий дизайнерский арт мебели
* остались в `lazyPdfFiles` (114 486 Б gzip). Новый центр даёт 944 Б сверху и 1 056 Б
* снизу; PDF-граф не пересекается с initial View.
*/
export const INITIAL_VIEW_GZIP_CEILING = 289_500;
export const INITIAL_VIEW_GZIP_CEILING = 290_500;
export const INITIAL_VIEW_CEILING_BAND = 2_000;
/**
@@ -280,6 +286,9 @@ export function assertBundleBudget(manifest, budget = INITIAL_VIEW_GZIP_BUDGET)
if (!manifest.lazyIsometricFiles?.length) {
throw new Error('bundle has no lazy isometric graph');
}
if (!manifest.lazyPdfFiles?.length) {
throw new Error('bundle has no lazy PDF graph');
}
// #474: designer furniture artwork is lazy; a static import anywhere in the
// View graph would pull ~10 KB gzip back into the initial graph silently.
if (!manifest.lazyFurnitureArtFiles?.length) {
@@ -297,6 +306,9 @@ export function assertBundleBudget(manifest, budget = INITIAL_VIEW_GZIP_BUDGET)
if (manifest.initialViewFiles.some((path) => manifest.lazyIsometricFiles.includes(path))) {
throw new Error('initial View graph overlaps lazy isometric graph');
}
if (manifest.initialViewFiles.some((path) => manifest.lazyPdfFiles.includes(path))) {
throw new Error('initial View graph overlaps lazy PDF graph');
}
if (manifest.lazyLocaleFiles.some((path) => manifest.lazyEditorFiles.includes(path)
|| manifest.lazyOnboardingFiles?.includes(path))) {
throw new Error('lazy locale graph overlaps an editor graph');
@@ -312,6 +324,7 @@ export function assertBundleBudget(manifest, budget = INITIAL_VIEW_GZIP_BUDGET)
lazyLocaleGzipBytes: manifest.lazyLocaleGzipBytes,
lazyIsometricGzipBytes: manifest.lazyIsometricGzipBytes,
lazyFurnitureArtGzipBytes: manifest.lazyFurnitureArtGzipBytes,
lazyPdfGzipBytes: manifest.lazyPdfGzipBytes,
};
}
@@ -330,6 +343,7 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
`lazy editor: ${result.lazyEditorGzipBytes} B gzip`,
`lazy locale: ${result.lazyLocaleGzipBytes} B gzip`,
`lazy isometric: ${result.lazyIsometricGzipBytes} B gzip`,
`lazy PDF: ${result.lazyPdfGzipBytes} B gzip`,
];
for (const line of lines) console.log(line);
const warning = lowHeadroomWarning(headroom);
+21 -2
View File
@@ -10,6 +10,7 @@ const ISO_RETRY_ASSET_TOKEN = '__HOUSEPLAN_ISO_RETRY_ASSET__';
const DE_RETRY_ASSET_TOKEN = '__HOUSEPLAN_DE_RETRY_ASSET__';
const FR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FR_RETRY_ASSET__';
const FURNITURE_ART_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FURNITURE_ART_RETRY_ASSET__';
const PDF_RETRY_ASSET_TOKEN = '__HOUSEPLAN_PDF_RETRY_ASSET__';
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
@@ -40,6 +41,8 @@ export function buildBundleManifest(bundle, fingerprint) {
? 'editor'
: modules.some((id) => id.endsWith('/src/iso-scene-render.ts'))
? 'isometric'
: modules.some((id) => id.endsWith('/src/pdf/pdf-export.ts'))
? 'pdf'
// #474: designer furniture artwork — its own lazy chunk, shared by
// the editor (static import) and the View runtime (dynamic).
: modules.some((id) => id.endsWith('/src/furniture-plan-art.generated.ts'))
@@ -77,6 +80,8 @@ export function buildBundleManifest(bundle, fingerprint) {
const isometricRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'isometric'
|| path.includes('iso-scene-render-'));
const furnitureArtRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'furniture-art');
const pdfRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'pdf'
|| path.includes('pdf-export-'));
const graphFrom = (roots) => {
const graph = new Set();
for (const root of roots) {
@@ -89,6 +94,7 @@ export function buildBundleManifest(bundle, fingerprint) {
const lazyLocale = graphFrom(localeRoots);
const lazyIsometric = graphFrom(isometricRoots);
const lazyFurnitureArt = graphFrom(furnitureArtRoots);
const lazyPdf = graphFrom(pdfRoots);
const sum = (paths) => [...paths]
.reduce((total, path) => total + (byPath.get(path)?.gzipBytes || 0), 0);
return {
@@ -109,6 +115,8 @@ export function buildBundleManifest(bundle, fingerprint) {
lazyIsometricGzipBytes: sum(lazyIsometric),
lazyFurnitureArtFiles: [...lazyFurnitureArt].sort(),
lazyFurnitureArtGzipBytes: sum(lazyFurnitureArt),
lazyPdfFiles: [...lazyPdf].sort(),
lazyPdfGzipBytes: sum(lazyPdf),
files: files.map(({ _role, ...file }) => file),
};
}
@@ -164,18 +172,22 @@ export function editorRuntimeRetryUrlPlugin() {
.some((id) => id.replaceAll('\\', '/').endsWith('/src/i18n/fr.ts')));
const furnitureArt = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/furniture-plan-art.generated.ts')));
const pdf = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/pdf/pdf-export.ts')));
if (!editor) throw new Error('editor runtime chunk was not emitted');
if (!onboarding) throw new Error('onboarding runtime chunk was not emitted');
if (!isometric) throw new Error('isometric runtime chunk was not emitted');
if (!german) throw new Error('German locale chunk was not emitted');
if (!french) throw new Error('French locale chunk was not emitted');
if (!furnitureArt) throw new Error('furniture artwork chunk was not emitted');
if (!pdf) throw new Error('PDF export runtime chunk was not emitted');
let furnitureArtReplacements = 0;
let editorReplacements = 0;
let onboardingReplacements = 0;
let isometricReplacements = 0;
let germanReplacements = 0;
let frenchReplacements = 0;
let pdfReplacements = 0;
for (const chunk of chunks) {
if (chunk.code.includes(EDITOR_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), editor.fileName);
@@ -213,11 +225,18 @@ export function editorRuntimeRetryUrlPlugin() {
furnitureArtReplacements += chunk.code.split(FURNITURE_ART_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(FURNITURE_ART_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(PDF_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), pdf.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
pdfReplacements += chunk.code.split(PDF_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(PDF_RETRY_ASSET_TOKEN, asset);
}
}
if (editorReplacements !== 1 || onboardingReplacements !== 1 || isometricReplacements !== 1
|| germanReplacements !== 1 || frenchReplacements !== 1 || furnitureArtReplacements !== 1) {
|| germanReplacements !== 1 || frenchReplacements !== 1 || furnitureArtReplacements !== 1
|| pdfReplacements !== 1) {
throw new Error('lazy retry URL placeholder counts are '
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}, expected 1/1/1/1/1/1`);
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}/${pdfReplacements}, expected 1/1/1/1/1/1/1`);
}
},
};
+56
View File
@@ -0,0 +1,56 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { Buffer } from 'node:buffer';
import subsetFont from 'subset-font';
import opentype from 'opentype.js';
const sourcePath = resolve('assets/fonts/Roboto-Regular.ttf');
const outputPath = resolve('src/pdf/pdf-font.generated.ts');
const ranges = [
[0x20, 0x7e], [0xa0, 0x17f], [0x400, 0x52f], [0x2000, 0x206f],
];
const required = new Set(['°', '²', '′', '″', '×', '≈', '€', '₽', '—', '→']);
for (const [start, end] of ranges) {
for (let code = start; code <= end; code++) required.add(String.fromCodePoint(code));
}
const characters = [...required].join('');
const original = readFileSync(sourcePath);
const subset = Buffer.from(await subsetFont(original, characters, { targetFormat: 'truetype' }));
const arrayBuffer = subset.buffer.slice(subset.byteOffset, subset.byteOffset + subset.byteLength);
const font = opentype.parse(arrayBuffer);
const glyphs = {};
const widths = {};
for (const char of characters) {
const code = char.codePointAt(0);
const glyph = font.charToGlyph(char);
if (!code || glyph.index === 0) continue;
glyphs[code] = glyph.index;
widths[glyph.index] = Math.round(glyph.advanceWidth || font.unitsPerEm * 0.5);
}
const generated = `/* Generated by scripts/generate-pdf-font.mjs. DO NOT EDIT. */
export const PDF_FONT_BASE64 = ${JSON.stringify(subset.toString('base64'))};
export const PDF_FONT_UNITS_PER_EM = ${font.unitsPerEm};
export const PDF_FONT_ASCENT = ${Math.round(font.ascender)};
export const PDF_FONT_DESCENT = ${Math.round(font.descender)};
export const PDF_FONT_BBOX = ${JSON.stringify([
Math.round(font.tables.head.xMin), Math.round(font.tables.head.yMin),
Math.round(font.tables.head.xMax), Math.round(font.tables.head.yMax),
])} as const;
export const PDF_FONT_GLYPHS: Readonly<Record<number, number>> = ${JSON.stringify(glyphs)};
export const PDF_FONT_WIDTHS: Readonly<Record<number, number>> = ${JSON.stringify(widths)};
`;
if (process.argv.includes('--check')) {
let current = '';
try { current = readFileSync(outputPath, 'utf8'); } catch {}
if (current !== generated) {
console.error('src/pdf/pdf-font.generated.ts is stale; run npm run pdf-font:generate');
process.exit(1);
}
} else {
writeFileSync(outputPath, generated);
console.log(`Generated ${outputPath} (${subset.length} bytes)`);
}
+130
View File
@@ -108,6 +108,136 @@ function relocateEditorPatch(patch, cardSource, editorSource) {
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
const MUTANT_DEFINITIONS = [
{
id: 'pdf-shared-wall-twice',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="shared wall architecture" test/pdf-scene.test.mjs',
because: 'a shared wall must be one canonical physical component on the printed sheet',
patches: [{
file: 'src/pdf/pdf-scene.ts',
find: " for (const component of built.geometry.components) commands.push({\n"
+ " kind: 'path', rings: geometryAllRings(component.geom).map((ring) => ring.map(pt)),\n"
+ ' fill: WALL, stroke: INK, width: 0.25 * MM,\n'
+ ' });',
replace: " for (const component of [...built.geometry.components, ...built.geometry.components]) commands.push({\n"
+ " kind: 'path', rings: geometryAllRings(component.geom).map((ring) => ring.map(pt)),\n"
+ ' fill: WALL, stroke: INK, width: 0.25 * MM,\n'
+ ' });',
}],
},
{
id: 'pdf-dimensions-ignore-toggle',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="respects names/dimensions" test/pdf-scene.test.mjs',
because: 'turning dimensions off must remove the external dimension chain as well as room labels',
patches: [{
file: 'src/pdf/pdf-scene.ts',
find: ' if (input.options.dimensions && built.geometry.roomGeom) {',
replace: ' if (built.geometry.roomGeom) {',
}],
},
{
id: 'pdf-room-edge-dropped',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="dense non-rectangular" test/pdf-scene.test.mjs',
because: 'every non-short edge must retain a direct value or a numbered callout',
patches: [{
file: 'src/pdf/pdf-scene.ts',
find: ' for (const edge of edges) {',
replace: ' for (const edge of edges.slice(0, -1)) {',
}],
},
{
id: 'pdf-outer-face-inside',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="external dimension normal" test/pdf-dimensions.test.mjs',
because: 'external dimensions belong outside the layout, never toward the room centroid',
patches: [{
file: 'src/pdf/pdf-dimensions.ts',
find: ' return [-inward[0], -inward[1]];',
replace: ' return [inward[0], inward[1]];',
}],
},
{
id: 'pdf-virtual-wall-solid',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="includes architecture" test/pdf-scene.test.mjs',
because: 'zero-thickness walls are identified by the dashed print convention',
patches: [{
file: 'src/pdf/pdf-scene.ts',
find: ' stroke: INK, width: 0.35 * MM, dash: [3 * MM, 2 * MM],',
replace: ' stroke: INK, width: 0.35 * MM,',
}],
},
{
id: 'pdf-font-no-tounicode',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="extractable Cyrillic" test/pdf-writer.test.mjs',
because: 'embedded Cyrillic text must remain extractable Unicode, not only visible glyph ids',
patches: [{
file: 'src/pdf/pdf-writer.ts',
find: ' /ToUnicode ${toUnicode} 0 R',
replace: '',
}],
},
{
id: 'pdf-backdrop-over-geometry',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="backdrop is below" test/pdf-scene.test.mjs',
because: 'a raster backdrop must be emitted before physical architecture in the PDF content stream',
patches: [{
file: 'src/pdf/pdf-scene.ts',
find: ' for (const raster of input.rasters || []) commands.push({\n'
+ " kind: 'image', imageId: raster.id, ...pt([raster.x, raster.y]).reduce((o, value, index) =>\n"
+ " ({ ...o, [index ? 'y' : 'x']: value }), {} as { x: number; y: number }),\n"
+ ' width: raster.drawWidth * pointPerUnit, height: raster.drawHeight * pointPerUnit,\n'
+ ' angle: raster.angle, opacity: raster.opacity,\n'
+ ' });\n',
replace: '',
}, {
file: 'src/pdf/pdf-scene.ts',
find: " for (const component of built.geometry.components) commands.push({\n"
+ " kind: 'path', rings: geometryAllRings(component.geom).map((ring) => ring.map(pt)),\n"
+ ' fill: WALL, stroke: INK, width: 0.25 * MM,\n'
+ ' });',
replace: " for (const component of built.geometry.components) commands.push({\n"
+ " kind: 'path', rings: geometryAllRings(component.geom).map((ring) => ring.map(pt)),\n"
+ ' fill: WALL, stroke: INK, width: 0.25 * MM,\n'
+ ' });\n'
+ ' for (const raster of input.rasters || []) commands.push({\n'
+ " kind: 'image', imageId: raster.id, ...pt([raster.x, raster.y]).reduce((o, value, index) =>\n"
+ " ({ ...o, [index ? 'y' : 'x']: value }), {} as { x: number; y: number }),\n"
+ ' width: raster.drawWidth * pointPerUnit, height: raster.drawHeight * pointPerUnit,\n'
+ ' angle: raster.angle, opacity: raster.opacity,\n'
+ ' });',
}],
},
{
id: 'pdf-devices-leak',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="devices never enter" test/pdf-scene.test.mjs',
because: 'the architectural PDF must not serialize HA markers or live device identity',
patches: [{
file: 'src/pdf/pdf-scene.ts',
find: ' // Architectural export deliberately has no marker/device projection pass.',
replace: " for (const marker of input.config.markers || []) commands.push({\n"
+ " kind: 'text', x: margin, y: fieldTop, text: marker.id, size: 6,\n"
+ ' });',
}],
},
{
id: 'pdf-scale-not-standard',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="standard scale" test/pdf-scene.test.mjs',
because: 'the printed scale must always come from the approved architectural series',
patches: [{
file: 'src/pdf/pdf-scene.ts',
find: ' const scale = choosePdfScale(physicalWidth, physicalHeight,\n'
+ ' fieldWidthMm - calloutWidthMm - dimensionReserveMm,\n'
+ ' fieldHeightMm - dimensionReserveMm);',
replace: ' const scale = 42;',
}],
},
{
id: 'resource-docs-flatten-current-yaml',
guard: 'node scripts/check-docs.mjs',
+10
View File
@@ -27,6 +27,16 @@
/** @type {SmokeLink[]} */
export const SMOKE_LINKS = [
{
symbols: [
'PdfExportRuntime', 'buildPdfPage', 'writePdf', 'stableDimensionEdges',
'_openPdfDialog', '_renderPdfDialog',
],
smokes: ['smoke_pdf_export.mjs'],
because: 'the #53 production-bundle scenario proves the administrator button, cold-View '
+ 'lazy boundary, real modal, remembered options, browser download and parseable A4 output; '
+ 'those browser and network effects are outside the pure PDF scene and writer tests',
},
{
symbols: [
'DEFAULT_LABELS', '_closePicker', '_commitHex', '_confirm',