tauri
4
src-tauri/.gitignore
vendored
Normal 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
28
src-tauri/Cargo.toml
Normal 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
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
20
src-tauri/capabilities/default.json
Normal 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"
|
||||
]
|
||||
}
|
||||
2
src-tauri/gen/android/app/src/main/AndroidManifest.xml
Normal 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
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
19
src-tauri/src/lib.rs
Normal 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
@ -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
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
247
src/lib/common/services/docx.service.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
50
src/lib/common/utils/platform.util.js
Normal 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;
|
||||
}
|
||||
}
|
||||