Перейти к содержимому
FastOffice

Перетащите книгу сюда

Чужой документ открывается байтами: оболочка доносит файл, разбор остаётся за швом контракта, а отказ приходит словами того, кто открывал — на витрине это заглушка, она берёт csv, tsv и txt, а на xlsx честно отказывает.

чужой документ открывается байтами: оболочка доносит файл, разбор остаётся за швом контракта, а отказ приходит словами того, кто открывал — на витрине это заглушка, она берёт csv, tsv и txt, а на xlsx честно отказывает
открыть в новой вкладке ↗Работающий пример. Ниже — его код целиком, файл за файлом.

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

Страница, которую открывает браузер.

index.html
<!doctype html>
<!--
Перетащите книгу сюда — НАСТОЯЩИЙ МАЛЕНЬКИЙ ПРОЕКТ.
Не «страница витрины», а тот проект, который встраивающий копирует к себе
целиком. Галерея примеров ЗАПУСКАЕТ эти файлы и ПОКАЗЫВАЕТ их же.
-->
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Перетащите книгу сюда — @f1</title>
</head>
<body>
<div data-example="open-bytes" class="flex h-screen w-screen flex-col bg-base-100 text-base-content">
<header class="border-b border-base-300 px-4 py-2">
<div class="text-sm font-semibold">Перетащите книгу сюда</div>
<div class="text-xs opacity-70">
Документ хозяина открывается БАЙТАМИ: оболочка читает файл и отдаёт его за шов контракта
вместе с именем — расширение выбирает разбор, файловой системы ядро не касается вовсе.
Своего разбора здесь нет ни строчки. Этот проект собран на ЗАГЛУШКЕ ядра: она разбирает
разделённый текст (csv, tsv, txt), а на xlsx отказывает — своими словами и своим кодом, а
не пустой книгой.
</div>
</header>
<!-- РАМКА ДЛЯ БРОСКА — СВОЯ, а не подписка на документ: подписка забирала
бы бросок у всей чужой страницы, на которой стоит лист. -->
<section
data-open-drop
class="m-4 flex flex-col items-center gap-3 rounded-card border-2 border-dashed border-base-300 px-4 py-6 text-center"
>
<div class="text-sm">Бросьте файл книги в эту рамку</div>
<div class="text-xs opacity-60">или выберите его обычным диалогом</div>
<input type="file" data-open-picker class="file-input file-input-sm file-input-bordered w-72" />
<!-- Расписка о последней попытке: до первой здесь честное «ещё ничего не
приносили», а не пустая книга, притворившаяся открытой. -->
<div id="answer">
<div class="text-xs opacity-50" data-open-idle>ещё ничего не приносили</div>
</div>
</section>
<div id="app" class="flex min-h-0 flex-1 flex-col"></div>
</div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>

Точка входа: здесь лист создаётся и сюда же приходят события.

src/main.ts
import { createSheetsCore } from "@f1/preset-sheets-core";
import { documentBytesToBase64 } from "@f1/sheets";
import { EXECUTOR, transport } from "./book.js";
import { hostAsks } from "./host.js";
import "./styles.css";
/**
* ПЕРЕТАЩИТЕ КНИГУ СЮДА — ВТОРАЯ ПОЛОВИНА ДОРОГИ.
*
* Первая работала давно: документ ОТДАЁТСЯ хозяину байтами
* (`save_workbook_subset_to_bytes` плюс расписка `HostSink`). Принять чужой
* документ было нечем — операция `open_workbook_subset_from_bytes` в контракте
* была, а звали её ниоткуда.
*
* ЧТО ЗДЕСЬ ДЕЛАЕТ ОБОЛОЧКА — И ЧТО НЕ ДЕЛАЕТ. Она читает файл в БАЙТЫ и
* отдаёт их за шов контракта вместе с именем файла. Всё. Ни разбора xlsx, ни
* разбора csv, ни взгляда в заголовок файла ради выбора дороги: «что это за
* документ» — семантика, то есть ядро (инвариант №1). Расширение имени выбирает
* разбор ТАМ, и сделано это ровно так же, как при открытии по пути.
*
* ПОЧЕМУ ПРИМЕР ЧЕСТЕН, КОГДА ОТКАЗЫВАЕТ. Этот проект собран на ЗАГЛУШКЕ ядра
* (`book.ts`), и та разбирает только разделённый текст — то же, что сама
* собирает при сохранении. Пакет xlsx она не разберёт и не притворится, что
* разобрала: страница показывает ЕЁ отказ, её словами и с её кодом. Пустая
* книга вместо отказа выглядела бы как «открылось» — это и есть тот fake-green,
* ради запрета которого заведён инвариант №3. С настоящим ядром на этой же
* дороге откроется и xlsx, и ods, и xlsb: код оболочки не изменится ни строкой,
* а имя исполнителя в расписке — ровно одной, в `book.ts`.
*
* ДВА ВХОДА, потому что их два у человека: файл БРОСАЮТ на страницу или выбирают
* диалогом. Дорога после этого одна — байты и имя, и объявлена она один раз.
*/
const { locale } = hostAsks();
const zone = document.querySelector<HTMLElement>("[data-open-drop]")!;
const picker = document.querySelector<HTMLInputElement>("[data-open-picker]")!;
const answer = document.getElementById("answer")!;
const core = createSheetsCore({ mount: document.getElementById("app")!, transport, locale });
/**
* ОДНА ДОРОГА НА ОБА ВХОДА: и брошенный файл, и выбранный диалогом приходят
* сюда. Оболочка делает ровно два шага — читает байты и зовёт SDK.
*/
async function take(file: File): Promise<void> {
const bytes = new Uint8Array(await file.arrayBuffer());
const workbook = core.f1.getActiveWorkbook();
const said = await workbook.openFromBytesAsync(file.name, documentBytesToBase64(bytes));
answer.innerHTML = said.ok
? `<div class="alert alert-success py-2 text-start text-xs" data-open-answer><span>Открыто ${EXECUTOR}: <b>${file.name}</b> (${bytes.length} байт) — лист «${said.sheetName}», листов ${said.sheetCount}, занято ${said.usedRange}.</span></div>`
: `<div class="alert alert-error py-2 text-start text-xs" data-open-refusal><span><b>${file.name}</b> не открыт: ${said.reason}${said.code ? ` (${said.code})` : ""}</span></div>`;
}
/* ПОДПИСКА — НА СВОЮ РАМКУ, а не на документ: подписка на документ забирала бы
бросок у всей чужой страницы, на которой стоит лист. */
zone.addEventListener("dragover", (event) => {
/* Без этого браузер откроет файл вместо страницы — то есть уронит пример на
самом жесте, ради которого он и написан. */
event.preventDefault();
zone.setAttribute("data-open-over", "true");
zone.classList.add("border-primary", "bg-primary/5");
});
zone.addEventListener("dragleave", () => {
zone.removeAttribute("data-open-over");
zone.classList.remove("border-primary", "bg-primary/5");
});
zone.addEventListener("drop", (event) => {
event.preventDefault();
zone.removeAttribute("data-open-over");
zone.classList.remove("border-primary", "bg-primary/5");
const file = (event as DragEvent).dataTransfer?.files?.[0];
if (file) void take(file);
});
picker.addEventListener("change", () => {
const file = picker.files?.[0];
if (file) void take(file);
});
import.meta.hot?.dispose(() => core.destroy());

Книга примера: данные, с которыми лист открывается.

src/book.ts
import { createTransport, transportExecutorName, transportSettingFromSearch } from "@f1/contract";
/**
* КНИГА-ЗАГЛУШКА — ВМЕСТО НАСТОЯЩЕГО ЯДРА.
*
* Транспорт контракта — единственное место, где лист разговаривает с ядром
* (инвариант №1). В продукте здесь стоит `wasm` или мост хоста, и меняется
* РОВНО эта строка.
*/
/**
* ТРАНСПОРТ ВЫБИРАЕТСЯ НАСТРОЙКОЙ, А НЕ ВПАЯН — проверено на живом ядре.
* Умолчание — заглушка; `?core=http://127.0.0.1:5507` уводит на живое ядро
* через мост `@f1/core-host`. Виджеты об этом не знают: выбор живёт здесь.
*/
const setting = transportSettingFromSearch(globalThis.location?.search ?? "", {
cellAt: (row, col) => (row === 0 && col === 0 ? "перетащите книгу сюда" : undefined),
});
export const transport = createTransport(setting);
/**
* КТО НА САМОМ ДЕЛЕ ОТКРЫЛ ДОКУМЕНТ — и почему это написано рядом с транспортом.
*
* Расписка успеха говорила «Открыто ядром», а за ней стояла строка выше:
* `createMockTransport`, то есть заглушка. Настоящее ядро в деле не участвует
* вовсе, и приписывать ему чужую работу — тот же fake-green, ради запрета
* которого заведён инвариант №3, только в словах, а не в кнопке. Проверено
* замером: на книге в полмиллиона строк эта самая «ядром» открытая книга
* показывала `A1:AA500000`, тогда как ядро отвечает `A1:O500000`.
*
* Имя исполнителя стоит ВПЛОТНУЮ к строке, которая его выбирает: заменят
* транспорт — правка на соседней строке, а не в другом конце файла. Правдивость
* подписи стережёт отдельная проверка витрины.
*/
export const EXECUTOR = transportExecutorName(setting);

Сторона хозяина страницы: что приложение отвечает листу и что от него получает.

src/host.ts
import { applyLocaleAttributes, resolveLocale, setLocale, type Locale } from "@f1/i18n";
/**
* ЧТО ХОЗЯИН СТРАНИЦЫ ДАЁТ ЛИСТУ — тема и язык.
*
* У встраивающего это уже есть своё: тему он знает из настроек, язык — из
* профиля пользователя. Здесь и то и другое читается из адреса
* (`?theme=dark&locale=ar`) ровно затем, чтобы на любое состояние можно было
* дать ссылку и чтобы его проверял обход: настройка, доступная только щелчком,
* недоступна ни ссылке, ни автоматической проверке.
*
* ПРИЗНАКИ СТАВЯТСЯ НА КОРЕНЬ ДОКУМЕНТА — потому что это делает ХОЗЯИН, а не
* лист. Лист читает тему оттуда, где стоит (`:host-context`), а язык получает
* СВОЙСТВОМ сборки: два листа на одной странице вправе говорить на разном.
*
* НАПРАВЛЕНИЕ ПИСЬМА — ИНТЕРФЕЙСА, А НЕ ДОКУМЕНТА. `dir="rtl"` зеркалит хром
* страницы; «Лист справа налево» из Excel — свойство КНИГИ, и сетка от
* арабского языка интерфейса не переворачивается. Это не пропуск, а решение
* (`locale.ts`).
*/
export function hostAsks(): { readonly theme: "light" | "dark"; readonly locale: Locale } {
const params = new URLSearchParams(location.search);
const theme = params.get("theme") === "dark" ? "dark" : "light";
const locale = resolveLocale(params.get("locale") ?? undefined, "ru");
document.documentElement.setAttribute("data-theme", theme);
/* Язык — один на процесс: словарь модульный, и ставит его хозяин страницы. */
setLocale(locale);
applyLocaleAttributes(document.documentElement, locale);
return { theme, locale };
}

Оформление страницы вокруг листа.

src/styles.css
/* ЛИСТ СТИЛЕЙ НАБОРА КОМПОНЕНТОВ — ЕДИНСТВЕННАЯ СТРОКА, КОТОРУЮ НЕЛЬЗЯ ЗАБЫТЬ.
Собственное оформление лист приносит в теневые корни сам, но утилиты
раскладки, которыми он держит свои органы, компилируются ЭТИМ листом. Без
него страница не просто выглядит иначе — вкладка ЗАВИСАЕТ (бесконечный цикл
замера у сетки без размеров). У потребителя вне репозитория это
`@fastoffices/formula-kit` из его node_modules. */
@import "@fastoffices/formula-kit"; /* см. примечание выше */
html,
body {
height: 100%;
margin: 0;
}

Из каких пакетов собран пример.

package.json
{
"name": "f1-example-open-bytes",
"private": true,
"type": "module",
"description": "Чужой документ открывается БАЙТАМИ: оболочка доносит файл до шва контракта, разбор остаётся за ним.",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"@f1/preset-sheets-core": "*",
"@f1/sheets": "*",
"@f1/contract": "*",
"@f1/i18n": "*"
},
"devDependencies": {
"vite": "^7.0.0"
}
}

Ассистент документации

Ответ собран из документации и может быть неточным — сверяйтесь с источниками.