This commit is contained in:
2026-09-22 15:58:10 +03:00
parent 41a775ea97
commit 3c282821a2
27 changed files with 5314 additions and 0 deletions

4
src-tauri/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas

4895
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

28
src-tauri/Cargo.toml Normal file
View File

@ -0,0 +1,28 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.6.3", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.11.3", features = [] }
tauri-plugin-log = "2"
tauri-plugin-fs = "2.5.2"
tauri-plugin-dialog = "2.7.3"
tauri-plugin-shell = "2.3.6"

3
src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,20 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default",
"fs:default",
"fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-exists",
"dialog:default",
"dialog:allow-open",
"dialog:allow-save",
"shell:default",
"shell:allow-open"
]
}

View File

@ -0,0 +1,2 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

19
src-tauri/src/lib.rs Normal file
View File

@ -0,0 +1,19 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View File

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}

40
src-tauri/tauri.conf.json Normal file
View File

@ -0,0 +1,40 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "unitprint",
"version": "0.1.0",
"identifier": "com.tauri.dev",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5174",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"title": "unitprint",
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"android": {
"debugApplicationIdSuffix": ".debug"
}
}
}

View File

@ -0,0 +1,247 @@
// src/lib/common/services/docx.service.js
import html2canvas from 'html2canvas';
import { renderAsync } from 'docx-preview';
const A4_HEIGHT_MM = 297;
const MM_PER_INCH = 25.4;
const TOLERANCE = 0.95;
const CANVAS_SCALE = 1.5;
const MIN_DPI = 50;
const MAX_DPI = 400;
/**
* Создаёт скрытый offscreen-контейнер для рендеринга DOCX.
* НЕ display:none (html2canvas не работает с ним), а position:fixed за экраном.
*/
function createOffscreenContainer() {
const el = document.createElement('div');
el.style.cssText =
'position:fixed;left:-9999px;top:0;width:794px;background:#fff;z-index:-1;pointer-events:none;';
el.setAttribute('aria-hidden', 'true');
document.body.appendChild(el);
return el;
}
/**
* Определяет реальный DPI рендерера docx-preview по высоте первой валидной секции.
* ВСЕГДА возвращает объект { dpi, pageHeightPx }.
*/
function detectRenderDPI(pageElements) {
const a4HeightInches = A4_HEIGHT_MM / MM_PER_INCH;
// Ищем первую секцию с ненулевой высотой (проверяем до 3 штук)
let measuredHeightPx = 0;
for (let i = 0; i < Math.min(pageElements.length, 3); i++) {
const h = pageElements[i].getBoundingClientRect().height;
if (h > 0) {
measuredHeightPx = h;
break;
}
}
// Fallback: если ни одна секция не имеет высоты или массив пуст
if (measuredHeightPx <= 0) {
console.warn(
'[docx.service] Все секции имеют нулевую высоту. Используем fallback DPI.'
);
const fallbackDPI = window.devicePixelRatio * 96;
return {
dpi: fallbackDPI,
pageHeightPx: a4HeightInches * fallbackDPI,
};
}
let detectedDPI = measuredHeightPx / a4HeightInches;
// Sanity-check
if (detectedDPI < MIN_DPI || detectedDPI > MAX_DPI) {
console.warn(
`[docx.service] Подозрительный DPI: ${detectedDPI.toFixed(1)} ` +
`(высота: ${measuredHeightPx.toFixed(0)}px). Fallback на devicePixelRatio * 96.`
);
detectedDPI = window.devicePixelRatio * 96;
measuredHeightPx = a4HeightInches * detectedDPI;
}
console.log(
`[docx.service] DPI: ${detectedDPI.toFixed(1)} | Высота страницы: ${measuredHeightPx.toFixed(0)}px`
);
return {
dpi: detectedDPI,
pageHeightPx: measuredHeightPx,
};
}
/**
* Нарезает секции на виртуальные страницы по вычисленной высоте A4.
* ВСЕГДА возвращает массив ClipInfo (может быть пустым).
*/
function splitSectionsByHeight(pageElements) {
const result = [];
if (!pageElements || pageElements.length === 0) {
console.warn('[docx.service] splitSectionsByHeight: пустой массив секций');
return result;
}
const metrics = detectRenderDPI(pageElements);
const pageHeightPx = metrics.pageHeightPx;
for (let idx = 0; idx < pageElements.length; idx++) {
const section = pageElements[idx];
const fullHeight = section.getBoundingClientRect().height;
if (fullHeight <= 0) continue;
let pageCount = Math.floor(fullHeight / pageHeightPx);
const remainder = fullHeight - pageCount * pageHeightPx;
if (remainder >= pageHeightPx * (1 - TOLERANCE)) {
pageCount += 1;
}
// Секция меньше одной страницы — берём как одну
if (pageCount === 0) pageCount = 1;
for (let i = 0; i < pageCount; i++) {
const clipY = i * pageHeightPx;
const clipHeight = Math.min(pageHeightPx, fullHeight - clipY);
result.push({
element: section,
clipY,
clipHeight,
originalIndex: idx,
detectedDPI: metrics.dpi,
});
}
}
return result;
}
/**
* Рендерит один виртуальный кусок с кропом из кэшированного canvas секции.
*/
async function renderClippedPage(clipInfo, allSections, canvasCache) {
// Показываем только нужную секцию
allSections.forEach((el) => {
el.style.display = el === clipInfo.element ? '' : 'none';
});
await document.fonts.ready;
await new Promise((r) => setTimeout(r, 80));
// Кэшируем canvas целой секции
let fullCanvas = canvasCache.get(clipInfo.element);
if (!fullCanvas) {
fullCanvas = await html2canvas(clipInfo.element, {
scale: CANVAS_SCALE,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
logging: false,
});
canvasCache.set(clipInfo.element, fullCanvas);
}
const destHeight = Math.round(clipInfo.clipHeight * CANVAS_SCALE);
const destWidth = fullCanvas.width;
if (destHeight <= 0 || destWidth <= 0) {
console.warn(
`[docx.service] Пустой кроп: clipY=${clipInfo.clipY}, clipH=${clipInfo.clipHeight}`
);
return '';
}
const croppedCanvas = document.createElement('canvas');
croppedCanvas.width = destWidth;
croppedCanvas.height = destHeight;
const ctx = croppedCanvas.getContext('2d');
if (!ctx) return '';
ctx.drawImage(
fullCanvas,
0,
Math.round(clipInfo.clipY * CANVAS_SCALE),
destWidth,
destHeight,
0,
0,
destWidth,
destHeight
);
return croppedCanvas.toDataURL('image/png');
}
/**
* Основной API сервиса.
* @param {File} file - .docx файл
* @param {(progress: string) => void} [onProgress] - опциональный callback прогресса
* @returns {Promise<{ images: string[], totalPages: number }>}
*/
export async function renderDocxToImages(file, onProgress) {
const container = createOffscreenContainer();
try {
// ШАГ 1: Рендеринг DOCX в скрытый DOM
onProgress?.('Рендеринг документа...');
container.innerHTML = '';
await renderAsync(file, container, null, {
className: 'docx',
inWrapper: true,
breakPages: true,
ignoreFonts: false,
useBase64URL: true,
});
// Небольшая пауза, чтобы браузер завершил layout offscreen-контейнера
await new Promise((r) => setTimeout(r, 100));
// ШАГ 2: Нарезка на виртуальные страницы
onProgress?.('Поиск страниц...');
const pageElements = Array.from(
container.querySelectorAll('.docx-wrapper > section.docx')
);
if (pageElements.length === 0) {
throw new Error(
'Не найдено ни одной страницы через .docx-wrapper > section.docx. ' +
'Проверьте структуру DOM в DevTools.'
);
}
const virtualPages = splitSectionsByHeight(pageElements);
// Защитная проверка: splitSectionsByHeight ВСЕГДА должна вернуть массив
if (!Array.isArray(virtualPages) || virtualPages.length === 0) {
throw new Error(
`splitSectionsByHeight вернула невалидный результат (${typeof virtualPages}, length=${virtualPages?.length}). ` +
`Проверьте detectRenderDPI и структуру DOM.`
);
}
onProgress?.(`Найдено ${virtualPages.length} стр. (${pageElements.length} секц.)`);
// ШАГ 3: Рендеринг каждой страницы
const images = [];
const canvasCache = new Map();
for (let i = 0; i < virtualPages.length; i++) {
onProgress?.(`Страница ${i + 1} из ${virtualPages.length}...`);
const img = await renderClippedPage(virtualPages[i], pageElements, canvasCache);
images.push(img);
}
return { images, totalPages: virtualPages.length };
} finally {
// ВСЕГДА убираем контейнер из DOM, даже при ошибке
if (container.parentNode) {
document.body.removeChild(container);
}
}
}

View File

@ -0,0 +1,50 @@
// src/lib/common/utils/platform.util.js
/**
* Определяет, запущено ли приложение внутри Tauri WebView.
* Безопасно работает в любом окружении (SSR, browser, Tauri).
* @returns {boolean}
*/
export function isTauri() {
return (
typeof window !== 'undefined' &&
window.__TAURI_INTERNALS__ !== undefined
);
}
/**
* Определяет мобильную платформу (работает и в Tauri, и в браузере).
* @returns {boolean}
*/
export function isMobile() {
if (typeof navigator === 'undefined') return false;
return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
}
/**
* Возвращает базовый URL API-сервера.
* В dev-режиме Vite проксирует /api, в production или на мобильном
* используется абсолютный URL из переменной окружения.
* @returns {string}
*/
export function getApiBaseUrl() {
// VITE_API_URL задаётся в .env / .env.production
// Пример: VITE_API_URL=https://api.example.com
return import.meta.env.VITE_API_URL || '';
}
/**
* Динамический импорт Tauri-плагина с защитой от ошибок в браузере.
* @template T
* @param {string} pluginName - имя пакета, например '@tauri-apps/plugin-fs'
* @returns {Promise<T | null>}
*/
export async function tauriImport(pluginName) {
if (!isTauri()) return null;
try {
return await import(/* @vite-ignore */ pluginName);
} catch (err) {
console.error(`[platform] Не удалось загрузить ${pluginName}:`, err);
return null;
}
}