mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 06:08:59 +00:00
feat: export current space as architectural PDF
Issue: #53 User-Visible: yes
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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)`);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user