diff --git a/src/lib/common/utils/file.util.js b/src/lib/common/utils/file.util.js index b45c86e..1c25421 100644 --- a/src/lib/common/utils/file.util.js +++ b/src/lib/common/utils/file.util.js @@ -3,13 +3,24 @@ /** * Определение типа файла * @param {File} file - * @returns {'image' | 'pdf' | 'other'} + * @returns {'image' | 'pdf' | 'office' | 'other'} */ export function detectFileType(file) { - if (!file) return 'other'; - if (file.type.startsWith('image/')) return 'image'; - if (file.type === 'application/pdf') return 'pdf'; - return 'other'; + if (!file) return 'other'; + if (file.type.startsWith('image/')) return 'image'; + if (file.type === 'application/pdf') return 'pdf'; + + // Офисные документы — определяем по расширению, + // т.к. MIME может быть пустым или некорректным + const ext = (file.name || '').toLowerCase().split('.').pop() || ''; + const officeExts = [ + 'doc', 'docx', 'odt', // текст + 'xls', 'xlsx', 'ods', // таблицы + 'ppt', 'pptx', 'odp', // презентации (на всякий случай) + ]; + if (officeExts.includes(ext)) return 'office'; + + return 'other'; } /** @@ -17,9 +28,9 @@ export function detectFileType(file) { * @param {import('$lib/pages/print/stores/print.store.svelte').FileEntry} entry */ export function pagesLabel(entry) { - if (!entry.totalPages || entry.totalPages === 0) return '...'; - if (!entry.selectedPages || entry.selectedPages.size === 0) return 'Нет'; - if (entry.selectedPages.size === entry.totalPages) return 'Все'; - if (entry.selectedPages.size === 1) return `стр. ${[...entry.selectedPages][0]}`; - return `${entry.selectedPages.size}/${entry.totalPages} стр.`; + if (!entry.totalPages || entry.totalPages === 0) return '...'; + if (!entry.selectedPages || entry.selectedPages.size === 0) return 'Нет'; + if (entry.selectedPages.size === entry.totalPages) return 'Все'; + if (entry.selectedPages.size === 1) return `стр. ${[...entry.selectedPages][0]}`; + return `${entry.selectedPages.size}/${entry.totalPages} стр.`; } diff --git a/src/lib/pages/print/PrintMenu.svelte b/src/lib/pages/print/PrintMenu.svelte index 343b05a..92f0954 100644 --- a/src/lib/pages/print/PrintMenu.svelte +++ b/src/lib/pages/print/PrintMenu.svelte @@ -1,89 +1,97 @@ {#if store.showPayment} - + {/if} {#if !store.activeFile && !store.showPayment} -
-
- -

Печать

-
+
+
+ +

Печать

+
-
- {#if store.files.length === 0} - fileInput.click()} /> - {:else} - {#each store.files as file (file.id)} - store.openPreview(f)} - onPageSelect={(f) => (f.fileType === 'pdf' ? store.openGallery(f) : store.openPreview(f))} - pagesLabel={store.pagesLabel} - /> - {/each} - - {/if} -
+
+ {#if store.files.length === 0} + fileInput.click()} /> + {:else} + {#each store.files as file (file.id)} + store.openPreview(f)} + onPageSelect={(f) => (f.fileType === 'pdf' ? store.openGallery(f) : store.openPreview(f))} + pagesLabel={store.pagesLabel} + /> + {/each} + + {/if} +
- + - -
+ + +
{/if} diff --git a/src/lib/pages/print/PrintUI.svelte b/src/lib/pages/print/PrintUI.svelte index 50f95ee..4284a73 100644 --- a/src/lib/pages/print/PrintUI.svelte +++ b/src/lib/pages/print/PrintUI.svelte @@ -1,230 +1,308 @@ {#if as === 'button'} - + {:else if as === 'icon-button'} - + {:else if as === 'counter'} -
- - {props.value} шт. - -
+
+ + {props.value} шт. + +
{:else if as === 'toggle-group'} -
- {#if props.label}{/if} -
- {#each props.options as opt} - {@const val = props.valueKey ? opt[props.valueKey] : opt} - {@const lbl = props.labelKey ? opt[props.labelKey] : opt} - - {/each} -
-
+
+ {#if props.label}{/if} +
+ {#each props.options as opt} + {@const val = props.valueKey ? opt[props.valueKey] : opt} + {@const lbl = props.labelKey ? opt[props.labelKey] : opt} + + {/each} +
+
{:else if as === 'file-card'} - {@const file = props.file} -
-
props.onToggle?.(file.id)}> - {file.expanded ? '▼' : '►'} - {file.file.name} - e.stopPropagation()}> - {#if file.previewUrl} - props.onPreview?.(file)} title="Предпросмотр">🔍 - {/if} - props.onRemove?.(file.id)} title="Удалить">✕ - -
- {#if file.expanded} -
- - -
-
- props.onPageSelect?.(file)} /> - (formatOpen = !formatOpen)} /> -
-
- props.onUpdate?.(file.id, { sides: v })} - /> - c.id === file.colorMode))]} - onchange={(v, i) => props.onUpdate?.(file.id, { colorMode: COLOR_MODES[i]?.id })} - /> -
-
+ {@const file = props.file} +
+
props.onToggle?.(file.id)}> + {file.expanded ? '▼' : '►'} - -
- props.onUpdate?.(file.id, { qualityIndex: QUALITY_LABELS.indexOf(v) })} - /> - props.onUpdate?.(file.id, { dpi: v })} - /> -
+ + {#if isOffice(file)} + + {file.file.name.split('.').pop()?.toUpperCase() ?? 'DOC'} + + {/if} - -
- - props.onUpdate?.(file.id, { copies: file.copies + 1 })} - onDecrement={() => props.onUpdate?.(file.id, { copies: Math.max(1, file.copies - 1) })} - /> -
-
+ {file.file.name} - props.onUpdate?.(file.id, { format: v })} - /> - {/if} -
+ e.stopPropagation()}> + + {#if file.previewUrl && !isOffice(file)} + props.onPreview?.(file)} title="Предпросмотр">🔍 + {/if} + props.onRemove?.(file.id)} title="Удалить">✕ + +
+ + {#if file.expanded} +
+ + {#if isOffice(file)} + +

+ Документ будет распечатан как есть — конвертация выполняется на сервере. +

+ +
+
+ props.onUpdate?.(file.id, { sides: v })} + /> + c.id === file.colorMode))]} + onchange={(v, i) => props.onUpdate?.(file.id, { colorMode: COLOR_MODES[i]?.id })} + /> +
+
+ +
+ props.onUpdate?.(file.id, { qualityIndex: QUALITY_LABELS.indexOf(v) })} + /> +
+ + {:else} + +
+
+ props.onPageSelect?.(file)} /> + (formatOpen = !formatOpen)} /> +
+
+ props.onUpdate?.(file.id, { sides: v })} + /> + c.id === file.colorMode))]} + onchange={(v, i) => props.onUpdate?.(file.id, { colorMode: COLOR_MODES[i]?.id })} + /> +
+
+ +
+ props.onUpdate?.(file.id, { qualityIndex: QUALITY_LABELS.indexOf(v) })} + /> + props.onUpdate?.(file.id, { dpi: v })} + /> +
+ {/if} + + +
+ + props.onUpdate?.(file.id, { copies: file.copies + 1 })} + onDecrement={() => props.onUpdate?.(file.id, { copies: Math.max(1, file.copies - 1) })} + /> +
+
+ + + {#if !isOffice(file)} + props.onUpdate?.(file.id, { format: v })} + /> + {/if} + {/if} +
{:else if as === 'upload-zone'} -
-

Загрузите файлы для печати

- Загрузить файл(ы) -
+
+

Загрузите файлы для печати

+ Загрузить файл(ы) +
{:else if as === 'footer'} -
- {#if props.filesCount > 0} -

- {props.filesCount} {props.plFiles(props.filesCount)} - {#if props.extraCopies > 0}, {props.extraCopies} {props.plCopies(props.extraCopies)}{/if} -

- {/if} -
- 10р/л от 1 - 9р/л от 25 - 4р/л от 1000 -
-
- Итого - {props.totalPrice}₽ -
- - {props.isPrinting ? 'Отправка...' : 'Далее'} - -
+
+ {#if props.filesCount > 0} +

+ {props.filesCount} {props.plFiles(props.filesCount)} + {#if props.extraCopies > 0}, {props.extraCopies} {props.plCopies(props.extraCopies)}{/if} +

+ {/if} +
+ 10р/л от 1 + 9р/л от 25 + 4р/л от 1000 +
+
+ Итого + {props.totalPrice}₽ +
+ + {props.isPrinting ? 'Отправка...' : 'Далее'} + +
{/if} diff --git a/src/lib/pages/print/store.svelte.js b/src/lib/pages/print/store.svelte.js index ca5e646..b74e134 100644 --- a/src/lib/pages/print/store.svelte.js +++ b/src/lib/pages/print/store.svelte.js @@ -1,243 +1,274 @@ // lib/pages/print/store.svelte.js + import { onDestroy, tick } from 'svelte'; import { getPricePerPage } from '$lib/common/utils/pricing.util.js'; import { detectFileType, pagesLabel as _pagesLabel } from '$lib/common/utils/file.util.js'; import { loadPdfDocument, renderPageToImage } from '$lib/common/services/pdf.service.js'; /** - * @typedef {{ id: number, file: File, previewUrl: string | null, fileType: 'image' | 'pdf' | 'other', expanded: boolean, format: 'A4' | 'A5', colorMode: 'bw' | 'color', qualityIndex: number, copies: number, totalPages: number, selectedPages: Set, pdfDoc: any }} FileEntry + * @typedef {{ + * id: number, file: File, previewUrl: string | null, + * fileType: 'image' | 'pdf' | 'office' | 'other', + * expanded: boolean, format: string, colorMode: string, + * qualityIndex: number, copies: number, sides: string, + * dpi: string, totalPages: number, + * selectedPages: Set, pdfDoc: any + * }} FileEntry */ - export function createPrintStore() { - let files = $state(/** @type {FileEntry[]} */ ([])); - let activeFile = $state(/** @type {FileEntry | null} */ (null)); - let viewMode = $state(/** @type {'gallery' | 'preview'} */ ('gallery')); - let showPayment = $state(false); - let isPrinting = $state(false); + let files = $state(/** @type {FileEntry[]} */ ([])); + let activeFile = $state(/** @type {FileEntry | null} */ (null)); + let viewMode = $state(/** @type {'gallery' | 'preview'} */ ('gallery')); + let showPayment = $state(false); + let isPrinting = $state(false); + let galleryThumbnails = $state([]); + let galleryLoading = $state(false); + let previewPagesCache = $state({}); + let isPreviewRendering = $state(false); + let uid = 0; - let galleryThumbnails = $state([]); - let galleryLoading = $state(false); - let previewPagesCache = $state({}); - let isPreviewRendering = $state(false); + const totalPrice = $derived( + files.reduce((sum, f) => { + const pages = f.selectedPages?.size || 0; + return sum + pages * f.copies * getPricePerPage(pages); + }, 0) + ); - let uid = 0; + const extraCopies = $derived(files.reduce((sum, f) => sum + (f.copies - 1), 0)); - const totalPrice = $derived( - files.reduce((sum, f) => { - const pages = f.selectedPages?.size || 0; - return sum + pages * f.copies * getPricePerPage(pages); - }, 0) - ); + function updateFile(id, changes) { + files = files.map((f) => (f.id === id ? { ...f, ...changes } : f)); + if (activeFile?.id === id) { + activeFile = files.find((f) => f.id === id); + } + } - const extraCopies = $derived(files.reduce((sum, f) => sum + (f.copies - 1), 0)); + async function ensurePdfLoaded(entry) { + if (entry.pdfDoc) return entry.pdfDoc; + const pdf = await loadPdfDocument(entry.file); + if (pdf) { + const numPages = pdf.numPages; + updateFile(entry.id, { + pdfDoc: pdf, + totalPages: numPages, + selectedPages: new Set(Array.from({ length: numPages }, (_, i) => i + 1)) + }); + } else { + updateFile(entry.id, { totalPages: 0 }); + } + return pdf; + } - function updateFile(id, changes) { - files = files.map((f) => (f.id === id ? { ...f, ...changes } : f)); - if (activeFile?.id === id) { - activeFile = files.find((f) => f.id === id); - } - } + async function addFiles(fileList) { + if (!fileList || fileList.length === 0) return; + const newFiles = []; - async function ensurePdfLoaded(entry) { - if (entry.pdfDoc) return entry.pdfDoc; - const pdf = await loadPdfDocument(entry.file); - if (pdf) { - const numPages = pdf.numPages; - updateFile(entry.id, { - pdfDoc: pdf, - totalPages: numPages, - selectedPages: new Set(Array.from({ length: numPages }, (_, i) => i + 1)) - }); - } else { - updateFile(entry.id, { totalPages: 0 }); - } - return pdf; - } + for (let i = 0; i < fileList.length; i++) { + const file = fileList[i]; + const fType = detectFileType(file); + const isOffice = fType === 'office'; - async function addFiles(fileList) { - if (!fileList || fileList.length === 0) return; - const newFiles = []; - for (let i = 0; i < fileList.length; i++) { - const file = fileList[i]; - const fType = detectFileType(file); - newFiles.push({ - id: ++uid, - file, - previewUrl: URL.createObjectURL(file), - fileType: fType, - expanded: false, - format: 'A4', - colorMode: 'bw', - qualityIndex: 1, - copies: 1, - totalPages: fType === 'image' ? 1 : 0, - selectedPages: fType === 'image' ? new Set([1]) : new Set(), - pdfDoc: null - }); - } - files = [...files, ...newFiles]; - for (const entry of newFiles) { - if (entry.fileType === 'pdf') ensurePdfLoaded(entry); - } - } + newFiles.push({ + id: ++uid, + file, + // Для офисных файлов превью не создаём + previewUrl: isOffice ? null : URL.createObjectURL(file), + fileType: fType, + expanded: false, + format: 'Авто', + colorMode: 'bw', + qualityIndex: 1, + copies: 1, + sides: 'Односторонняя', + dpi: 'Авто', + // Для офисных файлов: 1 «страница» для расчёта цены, + // реальное кол-во определит CUPS + totalPages: (fType === 'image' || isOffice) ? 1 : 0, + selectedPages: (fType === 'image' || isOffice) ? new Set([1]) : new Set(), + pdfDoc: null + }); + } - function removeFile(id) { - const f = files.find((x) => x.id === id); - if (f?.previewUrl) URL.revokeObjectURL(f.previewUrl); - files = files.filter((x) => x.id !== id); - } + files = [...files, ...newFiles]; - function toggleExpand(id) { - updateFile(id, { expanded: !files.find((f) => f.id === id)?.expanded }); - } + for (const entry of newFiles) { + if (entry.fileType === 'pdf') ensurePdfLoaded(entry); + // office и image — ничего дополнительно не загружаем + } + } - async function openGallery(entry) { - activeFile = entry; - viewMode = 'gallery'; - galleryThumbnails = []; - galleryLoading = true; - previewPagesCache = {}; - await tick(); + function removeFile(id) { + const f = files.find((x) => x.id === id); + if (f?.previewUrl) URL.revokeObjectURL(f.previewUrl); + files = files.filter((x) => x.id !== id); + } - const pdf = await ensurePdfLoaded(entry); - if (!pdf || entry.totalPages === 0) { - galleryLoading = false; - return; - } + function toggleExpand(id) { + updateFile(id, { expanded: !files.find((f) => f.id === id)?.expanded }); + } - const thumbs = []; - for (let i = 1; i <= entry.totalPages; i++) { - try { - thumbs.push(await renderPageToImage(pdf, i, 0.4)); - } catch { - thumbs.push(''); - } - galleryThumbnails = [...thumbs]; - } - galleryLoading = false; - } + async function openGallery(entry) { + activeFile = entry; + viewMode = 'gallery'; + galleryThumbnails = []; + galleryLoading = true; + previewPagesCache = {}; + await tick(); - async function openPreview(entry) { - activeFile = entry; - viewMode = 'preview'; - previewPagesCache = {}; - galleryThumbnails = []; - await tick(); - if (entry.fileType === 'pdf') { - const pdf = await ensurePdfLoaded(entry); - if (pdf && entry.totalPages > 0) { - preloadPreviewPages(entry, 1, 5); - } - } - } + const pdf = await ensurePdfLoaded(entry); + if (!pdf || entry.totalPages === 0) { + galleryLoading = false; + return; + } - async function preloadPreviewPages(entry, startPage, count) { - const currentFile = files.find((f) => f.id === entry.id); - const pdf = currentFile?.pdfDoc || entry.pdfDoc; - if (!pdf || isPreviewRendering) return; + const thumbs = []; + for (let i = 1; i <= entry.totalPages; i++) { + try { + thumbs.push(await renderPageToImage(pdf, i, 0.4)); + } catch { + thumbs.push(''); + } + galleryThumbnails = [...thumbs]; + } + galleryLoading = false; + } - isPreviewRendering = true; - const endPage = Math.min(startPage + count - 1, entry.totalPages); - const newCache = {}; + async function openPreview(entry) { + activeFile = entry; + viewMode = 'preview'; + previewPagesCache = {}; + galleryThumbnails = []; + await tick(); - for (let i = startPage; i <= endPage; i++) { - if (previewPagesCache[i]) continue; - try { - newCache[i] = await renderPageToImage(pdf, i, 1.5); - } catch (e) { - console.error(`Failed to render preview page ${i}`, e); - } - } + if (entry.fileType === 'pdf') { + const pdf = await ensurePdfLoaded(entry); + if (pdf && entry.totalPages > 0) { + preloadPreviewPages(entry, 1, 5); + } + } + } - if (Object.keys(newCache).length > 0) { - previewPagesCache = { ...previewPagesCache, ...newCache }; - } - isPreviewRendering = false; - } + async function preloadPreviewPages(entry, startPage, count) { + const currentFile = files.find((f) => f.id === entry.id); + const pdf = currentFile?.pdfDoc || entry.pdfDoc; + if (!pdf || isPreviewRendering) return; - function togglePageSelection(pageNum) { - if (!activeFile?.selectedPages) return; - const newSet = new Set(activeFile.selectedPages); - if (newSet.has(pageNum)) { - if (newSet.size > 1) newSet.delete(pageNum); - } else { - newSet.add(pageNum); - } - updateFile(activeFile.id, { selectedPages: newSet }); - } + isPreviewRendering = true; + const endPage = Math.min(startPage + count - 1, entry.totalPages); + const newCache = {}; - function selectAllPages() { - if (!activeFile?.totalPages) return; - updateFile(activeFile.id, { - selectedPages: new Set(Array.from({ length: activeFile.totalPages }, (_, i) => i + 1)) - }); - } + for (let i = startPage; i <= endPage; i++) { + if (previewPagesCache[i]) continue; + try { + newCache[i] = await renderPageToImage(pdf, i, 1.5); + } catch (e) { + console.error(`Failed to render preview page ${i}`, e); + } + } - function deselectAllPages() { - if (!activeFile) return; - updateFile(activeFile.id, { selectedPages: new Set([1]) }); - } + if (Object.keys(newCache).length > 0) { + previewPagesCache = { ...previewPagesCache, ...newCache }; + } + isPreviewRendering = false; + } - async function submitPrint() { - if (files.length === 0 || isPrinting) return; - isPrinting = true; - const formData = new FormData(); - for (const f of files) { - formData.append('files', f.file, f.file.name); - formData.append( - 'settings', - JSON.stringify({ - filename: f.file.name, - pages: f.selectedPages ? Array.from(f.selectedPages).sort((a, b) => a - b).join(',') : 'all', - copies: f.copies, - colorMode: f.colorMode, - format: f.format - }) - ); - } - try { - const res = await fetch('/api/print', { method: 'POST', body: formData }); - const text = await res.text(); - alert(res.ok ? `✅ Успешно!\n${text}` : `❌ Ошибка (${res.status}):\n${text}`); - } catch { - alert('⚠️ Не удалось связаться с сервером печати.'); - } finally { - isPrinting = false; - showPayment = false; - } - } + function togglePageSelection(pageNum) { + if (!activeFile?.selectedPages) return; + const newSet = new Set(activeFile.selectedPages); + if (newSet.has(pageNum)) { + if (newSet.size > 1) newSet.delete(pageNum); + } else { + newSet.add(pageNum); + } + updateFile(activeFile.id, { selectedPages: newSet }); + } - onDestroy(() => { - files.forEach((f) => f.previewUrl && URL.revokeObjectURL(f.previewUrl)); - }); + function selectAllPages() { + if (!activeFile?.totalPages) return; + updateFile(activeFile.id, { + selectedPages: new Set(Array.from({ length: activeFile.totalPages }, (_, i) => i + 1)) + }); + } - return { - get files() { return files; }, - get activeFile() { return activeFile; }, - get viewMode() { return viewMode; }, - get showPayment() { return showPayment; }, - get isPrinting() { return isPrinting; }, - get totalPrice() { return totalPrice; }, - get extraCopies() { return extraCopies; }, - get galleryThumbnails() { return galleryThumbnails; }, - get galleryLoading() { return galleryLoading; }, - get previewPagesCache() { return previewPagesCache; }, - get isPreviewRendering() { return isPreviewRendering; }, + function deselectAllPages() { + if (!activeFile) return; + updateFile(activeFile.id, { selectedPages: new Set([1]) }); + } - addFiles, - removeFile, - toggleExpand, - updateFile, - openGallery, - openPreview, - closeOverlay: () => { activeFile = null; galleryThumbnails = []; previewPagesCache = {}; }, - openPayment: () => { if (files.length > 0 && !isPrinting) showPayment = true; }, - hidePayment: () => { showPayment = false; }, - submitPrint, - togglePageSelection, - selectAllPages, - deselectAllPages, - requestPreviewPages: (start, count) => { if (activeFile) preloadPreviewPages(activeFile, start, count); }, - pagesLabel: _pagesLabel - }; + async function submitPrint() { + if (files.length === 0 || isPrinting) return; + isPrinting = true; + + const formData = new FormData(); + for (const f of files) { + formData.append('files', f.file, f.file.name); + + // Для офисных файлов всегда отправляем все страницы — + // CUPS сам разберётся + const pagesValue = + f.fileType === 'office' + ? 'all' + : f.selectedPages && f.selectedPages.size > 0 + ? Array.from(f.selectedPages).sort((a, b) => a - b).join(',') + : 'all'; + + formData.append( + 'settings', + JSON.stringify({ + filename: f.file.name, + pages: pagesValue, + copies: f.copies, + colorMode: f.colorMode, + format: f.fileType === 'office' ? 'auto' : f.format, + sides: f.sides ?? 'Односторонняя', + quality: f.qualityIndex, + dpi: f.dpi ?? 'Авто' + }) + ); + } + + try { + const res = await fetch('/api/print', { method: 'POST', body: formData }); + const text = await res.text(); + alert(res.ok ? `✅ Успешно!\n${text}` : `❌ Ошибка (${res.status}):\n${text}`); + } catch { + alert('⚠️ Не удалось связаться с сервером печати.'); + } finally { + isPrinting = false; + showPayment = false; + } + } + + onDestroy(() => { + files.forEach((f) => f.previewUrl && URL.revokeObjectURL(f.previewUrl)); + }); + + return { + get files() { return files; }, + get activeFile() { return activeFile; }, + get viewMode() { return viewMode; }, + get showPayment() { return showPayment; }, + get isPrinting() { return isPrinting; }, + get totalPrice() { return totalPrice; }, + get extraCopies() { return extraCopies; }, + get galleryThumbnails() { return galleryThumbnails; }, + get galleryLoading() { return galleryLoading; }, + get previewPagesCache() { return previewPagesCache; }, + get isPreviewRendering() { return isPreviewRendering; }, + addFiles, + removeFile, + toggleExpand, + updateFile, + openGallery, + openPreview, + closeOverlay: () => { activeFile = null; galleryThumbnails = []; previewPagesCache = {}; }, + openPayment: () => { if (files.length > 0 && !isPrinting) showPayment = true; }, + hidePayment: () => { showPayment = false; }, + submitPrint, + togglePageSelection, + selectAllPages, + deselectAllPages, + requestPreviewPages: (start, count) => { if (activeFile) preloadPreviewPages(activeFile, start, count); }, + pagesLabel: _pagesLabel + }; }