feat(tools): страница /convert для конвертера Sweet Home 3D, этап 2

Артефакт один — scripts/sh3d-convert/dist/index.html, самодостаточный
файл, как и лендинг: раскладывается копированием, без сборщиков и CDN.
Собирается склейкой тех же модулей, которые проверяют гейты этапа 1;
относительных импортов на странице нет, поэтому она работает из любого
каталога и без сети.

Три обещания страницы проверяются тестами, а не текстом:

1. Ни одного сетевого вызова и ни одной внешней загрузки. Ищутся формы
   ВЫЗОВА, а не имена API: первая редакция теста краснела на собственном
   комментарии, где эти API перечислены как запрещённые. Шрифты
   системные — <link> к шрифтам это внешний запрос при загрузке, а
   обещание «файл не покидает браузер» должно быть буквальным. Заодно
   поэтому на странице нет аналитики.
2. Конвертер внутри страницы даёт тот же результат, что модули
   репозитория: код извлекается из собранного артефакта, прогоняется на
   фикстуре и сравнивается с golden. Склейка, потерявшая модуль,
   краснеет.
3. Собранная страница не отстала от исходников: build-page.mjs --check
   сверяет закоммиченный артефакт с пересборкой.

Плюс полнота переводов: у каждой подписи есть ru и en, словари
совпадают по ключам, у каждого кода отчёта и каждого кода отказа есть
человеческий текст. Иначе пользователь увидит пустую надпись или
`not_zip`.

Отчёт показывается ДО скачивания и по этажам: сколько комнат, стен и
проёмов, какая клетка сетки, и полный список мест, где конвертер принял
решение, — спрямлённая дуга, обрезанная толщина, непривязанный проём,
пропущенный этаж без комнат. Это требование задачи: искать чужие ошибки
в готовом плане из сотен объектов хуже, чем прочитать их заранее.

Гайды (ru/en) получили раздел про импорт из Sweet Home 3D с прямой
записью, что это необязательный ярлык, а не шаг настройки: SCOPE обещает
«no external editors», и обходить это молчанием нельзя.

Свидетели этапа 2, все проверены отрицательным прогоном: добавленный в
страницу fetch, потерянный русский перевод, правка dist руками, склейка
без модуля sh3d.mjs — каждая краснит свой тест.

Гейты: npm test 1871 tests, 1870 pass, 0 fail; pytest без HA 378 passed,
3 skipped.

Осталось владельцу: раскладка на хост (из песочницы SSH недоступен) и
проверка на настоящем файле Sweet Home 3D.

Issue: #446
User-Visible: no
This commit is contained in:
Claude
2026-09-04 00:41:40 +03:00
parent 2695307ef0
commit f6cd99cef4
7 changed files with 1877 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
/**
* Сборка страницы /convert в один самодостаточный файл (#446).
*
* Артефакт один: `dist/index.html`. Так же устроен лендинг, и так же его
* раскладывают на хост — копированием, без сборщиков и без CDN.
*
* Модули склеиваются, а не грузятся по относительным путям: страница обязана
* работать из любого каталога и без сети. Склейка нарочно тупая — снимаются
* строки `import ... from '...'` и префикс `export `. Многострочный импорт
* ломает такую склейку молча, поэтому он ОТКАЗ, а не предупреждение.
*
* node scripts/sh3d-convert/build-page.mjs [--check]
*
* `--check` не пишет файл, а сверяет закоммиченный артефакт с пересборкой:
* этим тест ловит правку страницы или модулей без пересборки.
*/
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ORDER = ['xml.mjs', 'zip.mjs', 'convert.mjs', 'sh3d.mjs', 'page/app.mjs'];
const MARKER = '/* <!--BUNDLE--> */';
const stripModuleSyntax = (source, name) => {
const lines = source.split('\n');
const out = [];
for (const [index, line] of lines.entries()) {
if (/^\s*import\s/.test(line)) {
if (!/;\s*$/.test(line)) {
throw new Error(
`${name}:${index + 1}: многострочный import ломает склейку — запишите его в одну строку`);
}
continue;
}
out.push(line.replace(/^export\s+(?=const|function|class|async|let)/, ''));
}
const remaining = out.find((line) => /^\s*export\s/.test(line));
if (remaining) throw new Error(`${name}: не снят export: ${remaining.trim()}`);
return out.join('\n');
};
export function buildPage() {
const shell = readFileSync(join(HERE, 'page/shell.html'), 'utf8');
if (!shell.includes(MARKER)) throw new Error('в shell.html нет метки вставки');
const bundle = ORDER
.map((name) => `// --- ${name} ---\n${stripModuleSyntax(readFileSync(join(HERE, name), 'utf8'), name)}`)
.join('\n');
return shell.replace(MARKER, `${bundle}\nmount();`);
}
const page = buildPage();
const target = join(HERE, 'dist', 'index.html');
if (process.argv.includes('--check')) {
const committed = readFileSync(target, 'utf8');
if (committed !== page) {
console.error('FAILED: dist/index.html отстал от исходников —'
+ ' пересоберите: node scripts/sh3d-convert/build-page.mjs');
process.exit(1);
}
console.log('ok: dist/index.html совпадает с пересборкой');
} else {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, page);
console.log(`dist/index.html: ${page.length} Б`);
}
File diff suppressed because it is too large Load Diff
+288
View File
@@ -0,0 +1,288 @@
/**
* Страница /convert: интерфейс конвертера (#446).
*
* Вся конверсия идёт здесь, в браузере. Ни одного сетевого вызова на этой
* странице нет и быть не должно: план дома — приватные данные, а сама страница
* живёт на хосте, который по нашему описанию расходный и потенциально
* враждебный. Отсутствие `fetch`/`XMLHttpRequest`/`WebSocket`/`sendBeacon`
* проверяется тестом по собранному артефакту, а не обещанием в тексте.
*
* Отчёт показывается ДО скачивания — это требование задачи, а не украшение:
* человек должен узнать про спрямлённую дугу и непривязанную дверь заранее, а
* не искать их потом в плане из сотен объектов.
*/
import { readSh3d } from '../sh3d.mjs';
import { ConvertError, MIN_HOUSEPLAN, convertHome } from '../convert.mjs';
const TOOL_VERSION = 'sh3d-convert 0.1';
const MAX_FILE_BYTES = 64 * 1024 * 1024;
/** Словари вынесены в экспорт: полноту ключей проверяет тест страницы. */
export const TEXT = {
ru: {
'page.title': 'Конвертер Sweet Home 3D → House Plan',
'page.lead': 'Файл плана из Sweet Home 3D превращается в документ импорта House Plan — по одному на этаж.',
'page.privacy': 'Конверсия идёт в вашем браузере. Файл никуда не загружается: страница не делает ни одного сетевого запроса.',
'page.requires': `Требуется House Plan ${MIN_HOUSEPLAN} или новее.`,
'drop.title': 'Перетащите сюда файл .sh3d',
'drop.or': 'или',
'drop.button': 'Выбрать файл',
'drop.hint': 'Ничего не уходит с вашего компьютера',
'busy': 'Читаю файл…',
'result.title': 'Что получилось',
'result.download': 'Скачать этаж',
'result.rooms': 'комнат',
'result.walls': 'стен',
'result.openings': 'проёмов',
'result.cell': 'клетка сетки',
'result.skipped': 'этаж пропущен: комнат нет',
'result.notes': 'На что посмотреть после импорта',
'howto.title': 'Как импортировать',
'howto.1': 'Откройте карточку House Plan → Общие настройки → Резервная копия и перенос.',
'howto.2': 'Выберите импорт и укажите скачанный файл: House Plan покажет предпросмотр и ничего не запишет до подтверждения.',
'howto.3': 'Этаж добавится рядом с существующими пространствами. Привязку комнат к областям Home Assistant и расстановку устройств сделайте в редакторе — в файле их нет.',
'error.title': 'Не получилось',
'error.retry': 'Выбрать другой файл',
'back': 'На главную',
'note.vertices_snapped': 'Вершины комнат выровнены по осевым линиям стен: {count}. Sweet Home 3D обводит комнаты по внутренним граням, иначе общая стена превратилась бы в две.',
'note.curved_wall_straightened': 'Круглых стен спрямлено: {count}. Дуг в House Plan нет.',
'note.thickness_clamped': 'Стен толще {limit} см обрезано до предела: {count}.',
'note.edge_without_wall': 'Границ без стены: {count}. Такая граница останется открытой — стены в файле там не было.',
'note.opening_unhosted': 'Проём не привязался ни к одной стене и не перенесён: {id}.',
'note.opening_shortened': 'Проём шире стены и обрезан до неё: {id}.',
'note.opening_without_width': 'Проём без ширины пропущен: {id}.',
'note.room_without_polygon': 'Комната без контура пропущена: {id}.',
'note.room_collapsed': 'Контур комнаты рассыпался после выравнивания и пропущен: {id}.',
'note.level_without_rooms': 'Этаж «{title}» пропущен: в нём нет ни одной комнаты. House Plan строит геометрию по комнатам — нарисуйте их в Sweet Home 3D.',
'note.furniture_dropped': 'Мебель, материалы, свет и камеры не переносятся: House Plan — живая карта дома, а не редактор интерьера.',
'code.not_zip': 'Это не файл Sweet Home 3D: архив не читается.',
'code.entry_missing': 'В архиве нет Home.xml. Пересохраните план в Sweet Home 3D 5.3 или новее.',
'code.encrypted': 'Архив защищён паролем.',
'code.zip64': 'Такой архив (zip64) не поддерживается.',
'code.entry_too_large': 'Файл слишком большой.',
'code.entity_declaration': 'В файле объявлены XML-сущности — такой файл не читается.',
'code.entity_reference': 'В файле есть неизвестная XML-сущность.',
'code.nothing_to_convert': 'В плане нет ни одной комнаты. House Plan строит геометрию по комнатам: нарисуйте их в Sweet Home 3D и сохраните файл заново.',
'code.room_too_complex': 'Слишком сложный контур комнаты.',
'code.unit_not_metric': 'План сохранён не в метрических единицах.',
'code.file_too_large': 'Файл больше 64 МБ.',
'code.unknown': 'Неизвестная ошибка чтения файла.',
},
en: {
'page.title': 'Sweet Home 3D → House Plan converter',
'page.lead': 'Turns a Sweet Home 3D file into House Plan import documents — one per level.',
'page.privacy': 'The conversion runs in your browser. The file is never uploaded: this page makes no network requests at all.',
'page.requires': `Requires House Plan ${MIN_HOUSEPLAN} or newer.`,
'drop.title': 'Drop a .sh3d file here',
'drop.or': 'or',
'drop.button': 'Choose a file',
'drop.hint': 'Nothing leaves your computer',
'busy': 'Reading the file…',
'result.title': 'What came out',
'result.download': 'Download level',
'result.rooms': 'rooms',
'result.walls': 'walls',
'result.openings': 'openings',
'result.cell': 'grid cell',
'result.skipped': 'level skipped: no rooms',
'result.notes': 'What to check after the import',
'howto.title': 'How to import',
'howto.1': 'Open the House Plan card → General settings → Backup & transfer.',
'howto.2': 'Pick import and choose the downloaded file: House Plan shows a preview and writes nothing until you confirm.',
'howto.3': 'The level is added next to your existing spaces. Bind rooms to Home Assistant areas and place devices in the editor — the file carries neither.',
'error.title': 'That did not work',
'error.retry': 'Choose another file',
'back': 'Back to the site',
'note.vertices_snapped': 'Room vertices aligned to wall centrelines: {count}. Sweet Home 3D traces rooms along inner faces, so without this a shared wall would become two.',
'note.curved_wall_straightened': 'Curved walls straightened: {count}. House Plan has no arcs.',
'note.thickness_clamped': 'Walls thicker than {limit} cm clamped to the limit: {count}.',
'note.edge_without_wall': 'Boundaries without a wall: {count}. They stay open — there was no wall in the file.',
'note.opening_unhosted': 'This opening matched no wall and was skipped: {id}.',
'note.opening_shortened': 'Opening wider than its wall, shortened to fit: {id}.',
'note.opening_without_width': 'Opening without a width, skipped: {id}.',
'note.room_without_polygon': 'Room without an outline, skipped: {id}.',
'note.room_collapsed': 'Room outline collapsed after alignment and was skipped: {id}.',
'note.level_without_rooms': 'Level “{title}” skipped: it has no rooms. House Plan builds geometry from rooms — draw them in Sweet Home 3D.',
'note.furniture_dropped': 'Furniture, materials, lights and cameras are not carried over: House Plan is a live map of the home, not an interior editor.',
'code.not_zip': 'This is not a Sweet Home 3D file: the archive cannot be read.',
'code.entry_missing': 'The archive has no Home.xml. Re-save the plan in Sweet Home 3D 5.3 or newer.',
'code.encrypted': 'The archive is password protected.',
'code.zip64': 'This archive (zip64) is not supported.',
'code.entry_too_large': 'The file is too large.',
'code.entity_declaration': 'The file declares XML entities and cannot be read.',
'code.entity_reference': 'The file contains an unknown XML entity.',
'code.nothing_to_convert': 'The plan has no rooms. House Plan builds geometry from rooms: draw them in Sweet Home 3D and save the file again.',
'code.room_too_complex': 'A room outline is too complex.',
'code.unit_not_metric': 'The plan is not saved in metric units.',
'code.file_too_large': 'The file is larger than 64 MB.',
'code.unknown': 'Unknown error while reading the file.',
},
};
let lang = 'en';
const fill = (template, values) =>
String(template).replace(/\{(\w+)\}/g, (whole, key) =>
(values && values[key] !== undefined ? String(values[key]) : whole));
const t = (key, values) => fill(TEXT[lang][key] ?? TEXT.en[key] ?? key, values);
function applyLanguage(next) {
lang = TEXT[next] ? next : 'en';
document.documentElement.lang = lang;
try {
localStorage.setItem('hp-convert-lang', lang);
} catch {
// Приватный режим: язык просто не запомнится, это не ошибка.
}
for (const node of document.querySelectorAll('[data-i18n]')) {
node.textContent = t(node.dataset.i18n);
}
for (const node of document.querySelectorAll('[data-lang]')) {
node.classList.toggle('on', node.dataset.lang === lang);
}
if (lastResult) renderResult(lastResult);
}
let lastResult = null;
const element = (tag, className, text) => {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
};
const show = (id, visible) => {
document.getElementById(id).hidden = !visible;
};
function noteText(note) {
const key = `note.${note.code}`;
const known = TEXT[lang][key] || TEXT.en[key];
return known ? fill(known, note) : `${note.code}: ${JSON.stringify(note)}`;
}
function download(name, document_) {
const blob = new Blob([`${JSON.stringify(document_, null, 2)}\n`], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const link = element('a');
link.href = url;
link.download = name;
link.click();
// Ссылка живёт ровно до клика: держать её незачем, а память жалко.
setTimeout(() => URL.revokeObjectURL(url), 0);
}
function renderResult(result) {
lastResult = result;
const { documents, report, stem } = result;
const host = document.getElementById('levels');
host.textContent = '';
let downloadIndex = 0;
for (const level of report.levels) {
const card = element('div', 'level');
card.append(element('h3', null, level.title || level.id || '—'));
if (level.skipped) {
card.append(element('p', 'muted', t('result.skipped')));
} else {
const stats = element('p', 'stats');
stats.textContent = [
`${level.rooms} ${t('result.rooms')}`,
`${level.walls} ${t('result.walls')}`,
`${level.openings} ${t('result.openings')}`,
`${t('result.cell')}: ${level.cellCm} cm`,
].join(' · ');
card.append(stats);
const index = downloadIndex++;
const button = element('button', 'btn primary', t('result.download'));
button.addEventListener('click', () =>
download(`${stem}.${level.spaceId}.json`, documents[index]));
card.append(button);
}
const notes = [...(level.notes || [])];
if (notes.length) {
card.append(element('h4', 'notes-title', t('result.notes')));
const list = element('ul', 'notes');
for (const note of notes) list.append(element('li', null, noteText(note)));
card.append(list);
}
host.append(card);
}
const shared = element('ul', 'notes');
for (const item of report.items) shared.append(element('li', null, noteText(item)));
document.getElementById('shared-notes').replaceChildren(shared);
show('result', true);
show('error', false);
show('busy', false);
}
function renderError(code, detail) {
const key = `code.${code}`;
const known = TEXT[lang][key] || TEXT.en[key];
document.getElementById('error-text').textContent = known || t('code.unknown');
const details = document.getElementById('error-detail');
details.textContent = detail && !known ? detail : '';
details.hidden = !details.textContent;
show('error', true);
show('result', false);
show('busy', false);
}
async function handleFile(file) {
if (!file) return;
lastResult = null;
show('busy', true);
show('error', false);
show('result', false);
try {
if (file.size > MAX_FILE_BYTES) {
renderError('file_too_large');
return;
}
const bytes = new Uint8Array(await file.arrayBuffer());
const home = await readSh3d(bytes);
const { documents, report } = convertHome(home, { toolVersion: TOOL_VERSION });
renderResult({
documents,
report,
stem: file.name.replace(/\.sh3d$/i, '') || 'plan',
});
} catch (error) {
const code = error && error.code ? error.code : 'unknown';
renderError(code, error instanceof ConvertError ? error.message : String(error?.message || ''));
}
}
export function mount() {
const input = document.getElementById('file');
const zone = document.getElementById('drop');
input.addEventListener('change', () => handleFile(input.files[0]));
document.getElementById('pick').addEventListener('click', () => input.click());
document.getElementById('retry').addEventListener('click', () => input.click());
for (const event of ['dragenter', 'dragover']) {
zone.addEventListener(event, (raw) => {
raw.preventDefault();
zone.classList.add('over');
});
}
for (const event of ['dragleave', 'drop']) {
zone.addEventListener(event, () => zone.classList.remove('over'));
}
zone.addEventListener('drop', (raw) => {
raw.preventDefault();
handleFile(raw.dataTransfer?.files?.[0]);
});
for (const node of document.querySelectorAll('[data-lang]')) {
node.addEventListener('click', () => applyLanguage(node.dataset.lang));
}
let saved = null;
try {
saved = localStorage.getItem('hp-convert-lang');
} catch {
saved = null;
}
applyLanguage(saved || (String(navigator.language || '').toLowerCase().startsWith('ru') ? 'ru' : 'en'));
}
+118
View File
@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sweet Home 3D → House Plan converter</title>
<meta name="description" content="Convert a Sweet Home 3D .sh3d floor plan into a House Plan import document. Runs entirely in your browser: the file is never uploaded.">
<meta name="robots" content="index,follow">
<meta name="theme-color" content="#0b1420">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🏠</text></svg>">
<style>
/* Палитра лендинга. Шрифты — системные: страница не делает ни одного
сетевого запроса, включая шрифтовые, и работает без сети. */
:root{
--bg:#0b1420; --bg-2:#0e1a2b; --panel:rgba(18,32,52,.62); --panel-solid:#101d30;
--line:rgba(148,180,220,.14); --line-strong:rgba(148,180,220,.28);
--text:#e9f1fb; --muted:#93a7bf; --accent:#3ea6ff; --accent-soft:rgba(62,166,255,.14);
--amber:#ffb454; --ok:#3ecf9a; --danger:#ff5d5d; --radius:18px;
--font:"Manrope",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--bg);color:var(--text);line-height:1.55;
padding:32px 20px 64px;display:flex;justify-content:center}
main{width:100%;max-width:860px}
header{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:28px}
.brand{display:flex;align-items:center;gap:10px;color:var(--text);text-decoration:none;font-weight:700}
.langs{display:flex;gap:6px}
.langs button{background:transparent;border:1px solid var(--line);color:var(--muted);
border-radius:999px;padding:4px 12px;font:inherit;font-size:14px;cursor:pointer}
.langs button.on{border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
h1{font-size:clamp(24px,4vw,34px);line-height:1.2;margin-bottom:12px}
h2{font-size:20px;margin:28px 0 12px}
h3{font-size:17px;margin-bottom:6px}
h4{font-size:14px;color:var(--muted);font-weight:600;margin:12px 0 4px}
p{color:var(--muted)}
p.lead{color:var(--text);font-size:17px;margin-bottom:16px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);
padding:18px 20px;margin:14px 0}
.privacy{border-color:rgba(62,207,154,.35);background:rgba(62,207,154,.08)}
.requires{border-color:rgba(255,180,84,.35);background:rgba(255,180,84,.08)}
#drop{border:1.5px dashed var(--line-strong);border-radius:var(--radius);padding:36px 20px;
text-align:center;background:var(--bg-2);transition:border-color .15s,background .15s}
#drop.over{border-color:var(--accent);background:var(--accent-soft)}
#drop p{margin:6px 0}
.btn{font:inherit;font-weight:600;border-radius:12px;padding:10px 18px;cursor:pointer;
border:1px solid var(--line-strong);background:var(--panel-solid);color:var(--text)}
.btn.primary{background:var(--accent);border-color:transparent;color:#04121f}
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.level{background:var(--panel-solid);border:1px solid var(--line);border-radius:14px;
padding:16px 18px;margin:12px 0}
.stats{color:var(--text);font-size:15px;margin-bottom:12px}
.notes{margin:6px 0 0 18px;color:var(--muted);font-size:14px}
.notes li{margin:4px 0}
.muted{color:var(--muted)}
ol{margin-left:20px;color:var(--muted)}
ol li{margin:6px 0}
#error{border-color:rgba(255,93,93,.4);background:rgba(255,93,93,.08)}
#error-detail{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;
color:var(--muted);margin-top:8px;word-break:break-word}
footer{margin-top:36px;color:var(--muted);font-size:14px}
footer a{color:var(--accent)}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
</head>
<body>
<main>
<header>
<a class="brand" href="/">🏠 House Plan</a>
<div class="langs">
<button type="button" data-lang="ru">RU</button>
<button type="button" data-lang="en">EN</button>
</div>
</header>
<h1 data-i18n="page.title">Sweet Home 3D → House Plan converter</h1>
<p class="lead" data-i18n="page.lead"></p>
<div class="card privacy"><p data-i18n="page.privacy"></p></div>
<div class="card requires"><p data-i18n="page.requires"></p></div>
<div id="drop">
<h3 data-i18n="drop.title"></h3>
<p data-i18n="drop.or"></p>
<button class="btn primary" id="pick" type="button" data-i18n="drop.button"></button>
<p class="muted" data-i18n="drop.hint"></p>
<input id="file" type="file" accept=".sh3d,application/zip" hidden>
</div>
<div class="card" id="busy" hidden><p data-i18n="busy"></p></div>
<div class="card" id="error" hidden>
<h3 data-i18n="error.title"></h3>
<p id="error-text"></p>
<p id="error-detail" hidden></p>
<p><button class="btn" id="retry" type="button" data-i18n="error.retry"></button></p>
</div>
<section id="result" hidden>
<h2 data-i18n="result.title"></h2>
<div id="levels"></div>
<div id="shared-notes"></div>
<h2 data-i18n="howto.title"></h2>
<ol>
<li data-i18n="howto.1"></li>
<li data-i18n="howto.2"></li>
<li data-i18n="howto.3"></li>
</ol>
</section>
<footer>
<a href="/" data-i18n="back">Back to the site</a> ·
<a href="https://github.com/Matysh/houseplan-card">GitHub</a>
</footer>
</main>
<script type="module">
/* <!--BUNDLE--> */
</script>
</body>
</html>