Files
sejong-office/packages/frontend/scripts/copy-viewer-assets.mjs
2026-07-23 10:12:38 +09:00

453 lines
13 KiB
JavaScript

import { cp, mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const frontendDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
);
const rootDir = path.resolve(frontendDir, '../..');
const publicDir = path.join(frontendDir, 'public');
function resolvePackage(...parts) {
const candidates = [
path.join(frontendDir, 'node_modules', ...parts),
path.join(rootDir, 'node_modules', ...parts),
];
return candidates;
}
async function copyFirst(sources, destination) {
const output = path.join(publicDir, destination);
await mkdir(path.dirname(output), { recursive: true });
let lastError;
for (const source of sources) {
try {
await cp(source, output);
return output;
} catch (error) {
lastError = error;
}
}
throw lastError ?? new Error(`Missing asset for ${destination}`);
}
const cadAssets = [
[
resolvePackage('@mlightcad/cad-simple-viewer/dist/dxf-parser-worker.js'),
'assets/dxf-parser-worker.js',
],
[
resolvePackage(
'@mlightcad/cad-simple-viewer/dist/libredwg-parser-worker.js',
),
'assets/libredwg-parser-worker.js',
],
[
resolvePackage(
'@mlightcad/cad-simple-viewer/dist/mtext-renderer-worker.js',
),
'assets/mtext-renderer-worker.js',
],
[
resolvePackage('@noto-pdf-ts/fonts-kr/dist/fonts/NotoSansKR-VF.ttf'),
'viewer-assets/cad-resources/fonts/NotoSansKR-VF.ttf',
],
[
resolvePackage('@fontsource-variable/noto-sans-kr/LICENSE'),
'viewer-assets/cad-resources/fonts/OFL-1.1.txt',
],
];
const o3dvAssets = [
[
resolvePackage('rhino3dm/rhino3dm.min.js'),
'viewer-assets/o3dv/rhino3dm.min.js',
],
[
resolvePackage('rhino3dm/rhino3dm.wasm'),
'viewer-assets/o3dv/rhino3dm.wasm',
],
[
resolvePackage('web-ifc/web-ifc-api-iife.js'),
'viewer-assets/o3dv/web-ifc-api-iife.js',
],
[resolvePackage('web-ifc/web-ifc.wasm'), 'viewer-assets/o3dv/web-ifc.wasm'],
[
resolvePackage('draco3d/draco_decoder_nodejs.js'),
'viewer-assets/o3dv/draco_decoder_nodejs.min.js',
],
[
resolvePackage('occt-import-js/dist/occt-import-js.js'),
'viewer-assets/o3dv/occt-import-js.js',
],
[
resolvePackage('occt-import-js/dist/occt-import-js-worker.js'),
'viewer-assets/o3dv/occt-import-js-worker.js',
],
[
resolvePackage('occt-import-js/dist/occt-import-js.wasm'),
'viewer-assets/o3dv/occt-import-js.wasm',
],
[
resolvePackage('occt-import-js/dist/license.occt-import-js.txt'),
'viewer-assets/o3dv/licenses/license.occt-import-js.txt',
],
[
resolvePackage('occt-import-js/dist/license.occt.txt'),
'viewer-assets/o3dv/licenses/license.occt.txt',
],
[
resolvePackage('online-3d-viewer/LICENSE.md'),
'viewer-assets/o3dv/licenses/online-3d-viewer.LICENSE.md',
],
[
resolvePackage('rhino3dm/README.md'),
'viewer-assets/o3dv/licenses/rhino3dm.README.md',
],
];
await Promise.all(
[...cadAssets, ...o3dvAssets].map(([sources, destination]) =>
copyFirst(sources, destination),
),
);
const envmapCandidates = resolvePackage(
'online-3d-viewer/website/assets/envmaps/fishermans_bastion',
);
const envmapDest = path.join(
publicDir,
'viewer-assets/o3dv/envmaps/fishermans_bastion',
);
await mkdir(path.dirname(envmapDest), { recursive: true });
{
let copied = false;
for (const candidate of envmapCandidates) {
try {
await cp(candidate, envmapDest, { recursive: true });
copied = true;
break;
} catch {
// try next
}
}
if (!copied) {
throw new Error('Missing Online3DViewer envmap assets');
}
}
const resources = path.join(publicDir, 'viewer-assets', 'cad-resources');
await mkdir(path.join(resources, 'fonts'), { recursive: true });
await mkdir(path.join(resources, 'templates'), { recursive: true });
const fontManifest = `${JSON.stringify(
[
{
name: [
'Noto Sans KR',
'arial',
'batang',
'dotum',
'ghs',
'gulim',
'hztxt',
'isocpeur',
'malgun',
'malgun gothic',
'pmingliu',
'romans',
'sanss___',
'simplex',
'swisscbo',
'whgtxt',
'whs',
],
file: 'NotoSansKR-VF.ttf',
type: 'mesh',
description: 'Open-source Korean CAD fallback (SIL OFL 1.1)',
},
],
null,
2,
)}\n`;
await Promise.all([
writeFile(path.join(resources, 'fonts.json'), fontManifest),
writeFile(path.join(resources, 'fonts', 'fonts.json'), fontManifest),
]);
await writeFile(
path.join(resources, 'README.txt'),
[
'Noto Sans KR is bundled as the open-source Korean CAD fallback.',
'Additional CAD fonts may be placed here when redistribution is permitted.',
'The read-only viewer does not require a drawing template.',
'',
].join('\n'),
);
await writeFile(
path.join(publicDir, 'viewer-assets/o3dv/README.txt'),
[
'Self-hosted Online3DViewer external libraries.',
'CDN URLs in online-3d-viewer are rewritten to /viewer-assets/o3dv/.',
'',
].join('\n'),
);
const scannerAssets = [
[resolvePackage('jscanify/src/opencv.js'), 'viewer-assets/scanner/opencv.js'],
[resolvePackage('jscanify/src/jscanify.js'), 'viewer-assets/scanner/jscanify.js'],
[resolvePackage('jscanify/LICENSE'), 'viewer-assets/scanner/licenses/jscanify.LICENSE'],
[
resolvePackage('ppu-paddle-ocr/LICENSE'),
'viewer-assets/scanner/licenses/ppu-paddle-ocr.LICENSE',
],
[
resolvePackage('pdfjs-dist/build/pdf.worker.min.mjs'),
'viewer-assets/scanner/pdfjs/pdf.worker.min.mjs',
],
[
resolvePackage('pdfjs-dist/LICENSE'),
'viewer-assets/scanner/licenses/pdfjs-dist.LICENSE',
],
];
await Promise.all(
scannerAssets.map(([sources, destination]) =>
copyFirst(sources, destination),
),
);
{
const { access, readdir, rm } = await import('node:fs/promises');
const legacyTesseract = path.join(publicDir, 'viewer-assets/scanner/tesseract');
try {
await rm(legacyTesseract, { recursive: true, force: true });
} catch {
// ignore
}
const ortDest = path.join(publicDir, 'viewer-assets/scanner/ort');
await mkdir(ortDest, { recursive: true });
const ortNames = [
'ort-wasm-simd-threaded.wasm',
'ort-wasm-simd-threaded.mjs',
'ort-wasm-simd-threaded.jsep.wasm',
'ort-wasm-simd-threaded.jsep.mjs',
];
let ortCopied = false;
for (const candidate of resolvePackage('onnxruntime-web/dist')) {
try {
const entries = await readdir(candidate);
const wanted = ortNames.filter((name) => entries.includes(name));
if (wanted.length < ortNames.length) continue;
await Promise.all(
wanted.map((name) =>
cp(path.join(candidate, name), path.join(ortDest, name)),
),
);
ortCopied = true;
break;
} catch {
// try next
}
}
if (!ortCopied) {
throw new Error('Missing onnxruntime-web WASM assets');
}
const paddleDest = path.join(publicDir, 'viewer-assets/scanner/paddle');
await mkdir(paddleDest, { recursive: true });
const modelCacheDir = path.join(frontendDir, '.cache', 'paddle-ocr-models');
await mkdir(modelCacheDir, { recursive: true });
const MODEL_BASE =
'https://media.githubusercontent.com/media/PT-Perkasa-Pilar-Utama/ppu-paddle-ocr-models/main';
const DICT_BASE =
'https://raw.githubusercontent.com/PT-Perkasa-Pilar-Utama/ppu-paddle-ocr-models/main';
const paddleFiles = [
{
url: `${MODEL_BASE}/detection/PP-OCRv5_mobile_det_infer.onnx`,
name: 'PP-OCRv5_mobile_det_infer.onnx',
},
{
url: `${MODEL_BASE}/recognition/multi/korean/v5/korean_PP-OCRv5_mobile_rec_infer.onnx`,
name: 'korean_PP-OCRv5_mobile_rec_infer.onnx',
},
{
url: `${DICT_BASE}/recognition/multi/korean/v5/ppocrv5_korean_dict.txt`,
name: 'ppocrv5_korean_dict.txt',
},
];
async function downloadToCache(url, name) {
const cached = path.join(modelCacheDir, name);
try {
await access(cached);
return cached;
} catch {
// download
}
console.log(`downloading paddle model: ${name}`);
let lastError;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength < 64) {
throw new Error(`Unexpectedly small download for ${name}`);
}
await writeFile(cached, buffer);
return cached;
} catch (error) {
lastError = error;
console.warn(`retry ${attempt}/3 failed for ${name}:`, error);
}
}
throw lastError ?? new Error(`Failed to download ${name}`);
}
for (const file of paddleFiles) {
const cachedPath = await downloadToCache(file.url, file.name);
await cp(cachedPath, path.join(paddleDest, file.name));
}
await writeFile(
path.join(publicDir, 'viewer-assets/scanner/licenses/paddle-ocr-models.NOTICE.txt'),
[
'PP-OCRv5 ONNX models are sourced from:',
'https://github.com/PT-Perkasa-Pilar-Utama/ppu-paddle-ocr-models',
'upstream: https://www.paddleocr.ai/ (Apache-2.0)',
'',
].join('\n'),
);
}
await writeFile(
path.join(publicDir, 'viewer-assets/scanner/README.txt'),
[
'Self-hosted document scanner libraries (OpenCV.js, jscanify, ppu-paddle-ocr, pdf.js).',
'OCR: PP-OCRv5 Korean mobile (det + rec + dict) under paddle/.',
'ONNX Runtime Web WASM under ort/.',
'PDF.js worker under pdfjs/.',
'',
].join('\n'),
);
const O3DV_BASE = '/viewer-assets/o3dv/';
const replacements = [
[
'https://cdn.jsdelivr.net/npm/occt-import-js@0.0.22/dist/',
O3DV_BASE,
],
[
'https://cdn.jsdelivr.net/npm/rhino3dm@8.17.0/rhino3dm.min.js',
`${O3DV_BASE}rhino3dm.min.js`,
],
[
'https://cdn.jsdelivr.net/npm/web-ifc@0.0.68/web-ifc-api-iife.js',
`${O3DV_BASE}web-ifc-api-iife.js`,
],
[
'https://cdn.jsdelivr.net/npm/draco3d@1.5.7/draco_decoder_nodejs.min.js',
`${O3DV_BASE}draco_decoder_nodejs.min.js`,
],
];
/**
* CDN builds load OCCT via a blob Worker + importScripts. That fails for
* self-hosted absolute paths in some browsers. Use a same-origin Worker URL.
*/
function patchCreateOcctWorker(source) {
const moduleRe =
/function CreateOcctWorker\s*\([^)]*\)\s*\{[\s\S]*?URL\.createObjectURL\s*\(\s*blob\s*\)[\s\S]*?\}\s*(?=\s*function LoadExternalLibrary)/;
const moduleNext = `function CreateOcctWorker (worker)
{
return new Promise ((resolve, reject) => {
try {
if (occtWorkerUrl === null) {
occtWorkerUrl = '${O3DV_BASE}occt-import-js-worker.js';
}
resolve (new Worker (occtWorkerUrl));
} catch (error) {
reject (error);
}
});
}
`;
const minRe =
/function Wo\(r\)\{return new Promise\(\(e,t\)=>\{if\(vu!==null\)\{e\(new Worker\(vu\)\);return\}[\s\S]*?URL\.createObjectURL\(s\),e\(new Worker\(vu\)\)\}\)\.catch\(t\)\}\)\}/;
const minNext = `function Wo(r){return new Promise((e,t)=>{try{vu===null&&(vu="${O3DV_BASE}occt-import-js-worker.js"),e(new Worker(vu))}catch(n){t(n)}})}`;
let next = source;
let changed = false;
if (moduleRe.test(next)) {
next = next.replace(moduleRe, moduleNext);
changed = true;
}
if (minRe.test(next)) {
next = next.replace(minRe, minNext);
changed = true;
}
return { source: next, changed };
}
async function patchO3dvFile(relativePath) {
for (const candidate of resolvePackage(relativePath)) {
try {
let source = await readFile(candidate, 'utf8');
let changed = false;
for (const [from, to] of replacements) {
if (source.includes(from)) {
source = source.split(from).join(to);
changed = true;
}
}
const occt = patchCreateOcctWorker(source);
source = occt.source;
changed = changed || occt.changed;
if (changed) {
await writeFile(candidate, source);
console.log(`patched ${candidate}`);
} else if (!source.includes(`${O3DV_BASE}occt-import-js-worker.js`)) {
throw new Error(
`OCCT worker patch did not apply for ${candidate}`,
);
}
return;
} catch (error) {
if (error && /OCCT worker patch/.test(String(error.message))) throw error;
// try next path
}
}
throw new Error(`Could not patch ${relativePath}`);
}
await writeFile(
path.join(publicDir, 'viewer-assets/o3dv/occt-import-js-worker.js'),
[
`importScripts('${O3DV_BASE}occt-import-js.js');`,
'',
'onmessage = async function (ev)',
'{',
'\tlet modulOverrides = {',
'\t\tlocateFile: function (path) {',
`\t\t\treturn '${O3DV_BASE}' + String(path).split('/').pop();`,
'\t\t}',
'\t};',
'\tlet occt = await occtimportjs (modulOverrides);',
'\tlet result = occt.ReadFile (ev.data.format, ev.data.buffer, ev.data.params);',
'\tpostMessage (result);',
'};',
'',
].join('\n'),
);
console.log('wrote self-hosted occt-import-js-worker.js');
await patchO3dvFile('online-3d-viewer/build/engine/o3dv.module.js');
await patchO3dvFile('online-3d-viewer/build/engine/o3dv.min.js');