diff --git a/plugins/pdf-office-workbench/CHANGELOG.md b/plugins/pdf-office-workbench/CHANGELOG.md new file mode 100644 index 00000000..d92c66b9 --- /dev/null +++ b/plugins/pdf-office-workbench/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## v0.2.0 + +- 接入真实 PDF 文件读写:选择、合并、按页拆分和输出结果。 +- 新增批量 PDF 重命名,支持 `{name}` 与 `{index}` 模板。 +- 支持从 ZTools 超级面板接收 PDF 文件,并可打开结果所在文件夹。 +- 接入 `pdf-lib` 作为本地处理依赖,不上传用户文件。 diff --git a/plugins/pdf-office-workbench/README.md b/plugins/pdf-office-workbench/README.md new file mode 100644 index 00000000..a95ffcb3 --- /dev/null +++ b/plugins/pdf-office-workbench/README.md @@ -0,0 +1,22 @@ +# PDF 办公工坊 + +把 PDF 合并拆分、票据字段提取、文档批量重命名和批次汇总组合成一个办公插件。当前版本提供真实的本地 PDF 文件处理,不上传文件。 + +当前范围聚焦 PDF 与票据流程;Word、Excel、PPT 的原生编辑/转换仍属于后续能力,不在本版本中宣称支持。 + +## 首批能力 + +- 选择多个 PDF 并真实合并为一个 PDF。 +- 按页码表达式(如 `1-3,5`)真实拆分 PDF。 +- 使用 `{name}` / `{index}` 模板批量重命名 PDF 文件。 +- 从 ZTools 超级面板接收 PDF 文件。 +- 从 OCR 文本中提取发票字段。 +- 展示批次文档数、页数和体积。 + +## 验证 + +```bash +npm test +``` + +插件运行时需要 ZTools 注入的文件对话框和 preload 能力;PDF 内容在本机通过 `pdf-lib` 处理。 diff --git a/plugins/pdf-office-workbench/index.html b/plugins/pdf-office-workbench/index.html new file mode 100644 index 00000000..b6b26cb7 --- /dev/null +++ b/plugins/pdf-office-workbench/index.html @@ -0,0 +1,222 @@ + + + + + + + PDF 办公工坊 + + + +
+
+
+
PDF WORKBENCH
+

PDF 办公工坊

+

在本机完成合并、拆分、批量重命名和票据字段整理。

+
+ +
+ +
+
+
+
+

文件队列

+
尚未选择文件
+
+ +
+
选择或从超级面板传入 PDF 文件
+ +
+

合并 PDF

按队列顺序输出
+ +
+
+ +
+

拆分 PDF

例如 1-3,5,8-9
+ +
+
+ +
+

批量重命名

支持 {name}、{index}
+ +
+
+
+ + +
+
+ + + + diff --git a/plugins/pdf-office-workbench/logo.svg b/plugins/pdf-office-workbench/logo.svg new file mode 100644 index 00000000..e6484388 --- /dev/null +++ b/plugins/pdf-office-workbench/logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plugins/pdf-office-workbench/package-lock.json b/plugins/pdf-office-workbench/package-lock.json new file mode 100644 index 00000000..837f3b7d --- /dev/null +++ b/plugins/pdf-office-workbench/package-lock.json @@ -0,0 +1,15 @@ +{ + "name": "ztools-pdf-office-workbench", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ztools-pdf-office-workbench", + "version": "0.2.0", + "engines": { + "node": ">=20.0.0" + } + } + } +} diff --git a/plugins/pdf-office-workbench/package.json b/plugins/pdf-office-workbench/package.json new file mode 100644 index 00000000..f279ea7c --- /dev/null +++ b/plugins/pdf-office-workbench/package.json @@ -0,0 +1,13 @@ +{ + "name": "ztools-pdf-office-workbench", + "version": "0.2.0", + "private": true, + "type": "module", + "description": "ZTools PDF and office document workflow plugin.", + "scripts": { + "test": "node --test tests/*.test.mjs" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/plugins/pdf-office-workbench/plugin.json b/plugins/pdf-office-workbench/plugin.json new file mode 100644 index 00000000..795ddccf --- /dev/null +++ b/plugins/pdf-office-workbench/plugin.json @@ -0,0 +1,36 @@ +{ + "name": "pdf-office-workbench", + "title": "PDF 办公工坊", + "description": "本地 PDF 和票据处理工作台:真实合并拆分、批量重命名、票据字段提取和批次汇总。", + "author": "harris", + "version": "0.2.0", + "main": "index.html", + "preload": "preload/services.js", + "logo": "logo.svg", + "categories": ["productivity", "text"], + "pluginSetting": { + "single": true, + "height": 720 + }, + "features": [ + { + "code": "pdf-office-workbench", + "explain": "PDF 办公工坊", + "icon": "logo.svg", + "cmds": [ + "PDF 办公工坊", + "PDF 合并", + "PDF 拆分", + "发票提取", + "文档重命名", + { + "type": "files", + "fileType": "file", + "label": "处理 PDF", + "extensions": ["pdf"], + "maxLength": 300 + } + ] + } + ] +} diff --git a/plugins/pdf-office-workbench/preload/package-lock.json b/plugins/pdf-office-workbench/preload/package-lock.json new file mode 100644 index 00000000..a8ecf243 --- /dev/null +++ b/plugins/pdf-office-workbench/preload/package-lock.json @@ -0,0 +1,54 @@ +{ + "name": "preload", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "pdf-lib": "1.17.1" + } + }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmmirror.com/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + } + } +} diff --git a/plugins/pdf-office-workbench/preload/package.json b/plugins/pdf-office-workbench/preload/package.json new file mode 100644 index 00000000..1f3abd15 --- /dev/null +++ b/plugins/pdf-office-workbench/preload/package.json @@ -0,0 +1,7 @@ +{ + "type": "commonjs", + "private": true, + "dependencies": { + "pdf-lib": "1.17.1" + } +} diff --git a/plugins/pdf-office-workbench/preload/services.js b/plugins/pdf-office-workbench/preload/services.js new file mode 100644 index 00000000..cc9d0502 --- /dev/null +++ b/plugins/pdf-office-workbench/preload/services.js @@ -0,0 +1,219 @@ +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { PDFDocument } = require('pdf-lib') + +let shell +try { + shell = require('electron').shell +} catch (_) { + shell = null +} + +function hostPath(name, fallback) { + try { + const value = window.ztools?.getPath?.(name) + if (value) return value + } catch (_) { + // Standalone previews do not inject window.ztools. + } + return fallback +} + +function downloadsDirectory() { + return hostPath('downloads', path.join(os.homedir(), 'Downloads')) +} + +function normalizePaths(value) { + if (typeof value === 'string') return value ? [value] : [] + if (!Array.isArray(value)) return [] + return value + .flatMap(item => { + if (typeof item === 'string') return item + if (!item || typeof item !== 'object') return [] + return item.path || item.filePath || item.url || [] + }) + .filter(item => typeof item === 'string' && item.length > 0) +} + +function basenameWithoutExtension(filePath) { + const name = path.basename(String(filePath)) + const extension = path.extname(name) + return extension ? name.slice(0, -extension.length) : name +} + +function sanitizeFileName(value) { + return String(value || '未命名') + .trim() + .replace(/[\\/:*?"<>|]/g, '-') + .replace(/[. ]+$/g, '') || '未命名' +} + +function parsePageRanges(expression) { + const ranges = String(expression ?? '') + .split(',') + .map(part => part.trim()) + .filter(Boolean) + .map(part => { + const match = /^(\d+)(?:\s*-\s*(\d+))?$/.exec(part) + if (!match) throw new Error(`无效页码范围:${part}`) + const start = Number(match[1]) + const end = Number(match[2] || match[1]) + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start) { + throw new Error(`无效页码范围:${part}`) + } + return [start, end] + }) + if (ranges.length === 0) throw new Error('请输入至少一个页码范围') + return ranges +} + +function ensurePdfPath(filePath) { + const resolved = path.resolve(String(filePath || '')) + if (!resolved.toLowerCase().endsWith('.pdf')) throw new Error(`仅支持 PDF 文件:${resolved}`) + if (!fs.existsSync(resolved)) throw new Error(`文件不存在:${resolved}`) + return resolved +} + +async function readPdfInfo(filePath) { + const resolved = ensurePdfPath(filePath) + const bytes = fs.readFileSync(resolved) + const document = await PDFDocument.load(bytes, { ignoreEncryption: true }) + return { + path: resolved, + name: path.basename(resolved), + pages: document.getPageCount(), + size: bytes.length + } +} + +async function chooseFiles() { + const selected = await Promise.resolve(window.ztools?.showOpenDialog?.({ + properties: ['openFile', 'multiSelections'], + filters: [{ name: 'PDF 文件', extensions: ['pdf'] }] + })) + const paths = normalizePaths(selected) + const results = [] + for (const filePath of paths) { + try { + results.push(await readPdfInfo(filePath)) + } catch (error) { + window.ztools?.showNotification?.(error instanceof Error ? error.message : String(error)) + } + } + return results +} + +async function chooseSavePath(defaultPath) { + return await Promise.resolve(window.ztools?.showSaveDialog?.({ + defaultPath, + filters: [{ name: 'PDF 文件', extensions: ['pdf'] }] + })) +} + +async function mergePdfs(paths, outputPath) { + const sources = normalizePaths(paths).map(ensurePdfPath) + if (sources.length < 2) throw new Error('至少选择两个 PDF 文件才能合并') + const output = path.resolve(String(outputPath || '')) + if (!output.toLowerCase().endsWith('.pdf')) throw new Error('合并输出文件必须是 .pdf') + if (sources.includes(output)) throw new Error('输出文件不能覆盖输入文件') + + const merged = await PDFDocument.create() + let totalPages = 0 + for (const source of sources) { + const document = await PDFDocument.load(fs.readFileSync(source), { ignoreEncryption: true }) + const pages = await merged.copyPages(document, document.getPageIndices()) + pages.forEach(page => merged.addPage(page)) + totalPages += pages.length + } + fs.mkdirSync(path.dirname(output), { recursive: true }) + fs.writeFileSync(output, await merged.save()) + return { path: output, pages: totalPages, size: fs.statSync(output).size } +} + +async function splitPdf(sourcePath, expression, outputDirectory) { + const source = ensurePdfPath(sourcePath) + const ranges = parsePageRanges(expression) + const sourceDocument = await PDFDocument.load(fs.readFileSync(source), { ignoreEncryption: true }) + const pageCount = sourceDocument.getPageCount() + const outputDir = path.resolve(String(outputDirectory || path.join(downloadsDirectory(), 'PDF 办公工坊'))) + fs.mkdirSync(outputDir, { recursive: true }) + const base = basenameWithoutExtension(source) + const outputs = [] + + for (const [start, end] of ranges) { + if (end > pageCount) throw new Error(`页码 ${start}-${end} 超出文档总页数 ${pageCount}`) + const document = await PDFDocument.create() + const pages = await document.copyPages( + sourceDocument, + Array.from({ length: end - start + 1 }, (_, offset) => start - 1 + offset) + ) + pages.forEach(page => document.addPage(page)) + const suffix = start === end ? `p${start}` : `p${start}-${end}` + const output = path.join(outputDir, `${sanitizeFileName(base)}-${suffix}.pdf`) + fs.writeFileSync(output, await document.save()) + outputs.push({ path: output, pages: pages.length, size: fs.statSync(output).size }) + } + return outputs +} + +async function renameFiles(paths, template) { + const sources = normalizePaths(paths).map(item => path.resolve(item)) + if (!sources.length) throw new Error('请先选择需要重命名的 PDF 文件') + const pattern = String(template || '{name}-{index}') + const operations = sources.map((source, index) => { + const name = basenameWithoutExtension(source) + const targetName = sanitizeFileName(pattern + .replace(/\{name\}/g, name) + .replace(/\{index\}/g, String(index + 1))) + return { source, target: path.join(path.dirname(source), `${targetName}.pdf`) } + }) + const targets = new Set() + for (const operation of operations) { + if (targets.has(operation.target)) throw new Error(`重命名目标重复:${operation.target}`) + targets.add(operation.target) + if (operation.target !== operation.source && fs.existsSync(operation.target) && !sources.includes(operation.target)) { + throw new Error(`目标文件已存在:${operation.target}`) + } + } + for (const operation of operations) { + if (operation.source !== operation.target) fs.renameSync(operation.source, operation.target) + } + return operations.map(operation => ({ path: operation.target, name: path.basename(operation.target) })) +} + +const services = { + chooseFiles, + chooseSavePath, + readPdfInfo, + mergePdfs, + splitPdf, + renameFiles, + getDefaultOutputDirectory() { + return path.join(downloadsDirectory(), 'PDF 办公工坊') + }, + reveal(filePath) { + if (shell?.showItemInFolder) shell.showItemInFolder(filePath) + }, + async handlePluginEnter(action) { + const paths = normalizePaths(action?.payload) + if (!paths.length) return + const files = [] + for (const filePath of paths) { + try { + files.push(await readPdfInfo(filePath)) + } catch (_) { + // Ignore non-PDF launch payloads and let the user choose files manually. + } + } + if (files.length) window.dispatchEvent(new CustomEvent('pdf-office-enter', { detail: { files, action } })) + } +} + +window.services = services + +window.ztools?.onPluginEnter?.(action => { + services.handlePluginEnter(action).catch(error => { + window.ztools?.showNotification?.(error instanceof Error ? error.message : String(error)) + }) +}) diff --git a/plugins/pdf-office-workbench/src/core/index.mjs b/plugins/pdf-office-workbench/src/core/index.mjs new file mode 100644 index 00000000..df60d7eb --- /dev/null +++ b/plugins/pdf-office-workbench/src/core/index.mjs @@ -0,0 +1,87 @@ +export function buildPdfMergePlan(files, options = {}) { + const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path)); + return { + outputName: options.outputName || 'merged.pdf', + totalPages: sorted.reduce((sum, file) => sum + (file.pages || 0), 0), + files: sorted.map(file => file.path) + }; +} + +export function buildPdfSplitPlan(source, expression) { + const base = basename(source); + return parsePageRanges(expression).map(([start, end]) => { + return { + source, + range: [start, end], + targetName: `${base}-p${start}${end === start ? '' : `-${end}`}.pdf` + }; + }); +} + +/** Parse a user-facing page expression such as "1-3,5,8-9". */ +export function parsePageRanges(expression) { + const ranges = String(expression ?? '') + .split(',') + .map(part => part.trim()) + .filter(Boolean) + .map(part => { + const match = /^(\d+)(?:\s*-\s*(\d+))?$/.exec(part); + if (!match) throw new Error(`无效页码范围:${part}`); + const start = Number(match[1]); + const end = Number(match[2] || match[1]); + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start) { + throw new Error(`无效页码范围:${part}`); + } + return [start, end]; + }); + + if (ranges.length === 0) throw new Error('请输入至少一个页码范围'); + return ranges; +} + +export function extractInvoiceFields(text) { + const source = String(text); + return { + invoiceNumber: pick(source, /发票号码[::\s]*([A-Za-z0-9-]+)/), + date: pick(source, /开票日期[::\s]*(\d{4}-\d{2}-\d{2})/), + amount: pick(source, /(?:金额|价税合计)[^\d]*(\d+(?:\.\d{1,2})?)/), + buyer: pick(source, /购买方[::\s]*([^\n\r]+)/) + }; +} + +export function buildDocumentRenamePlan(documents, template) { + return documents.map((document, index) => { + const fields = document.fields || {}; + const hasFields = Object.keys(fields).length > 0; + const targetName = hasFields + ? template.replace(/\{(\w+)\}/g, (_, key) => sanitize(fields[key] || '未命名')) + : `未命名-${index + 1}`; + return { + source: document.path, + targetName: `${targetName}.pdf` + }; + }); +} + +export function summarizePdfBatch(files) { + return { + documents: files.length, + pages: files.reduce((sum, file) => sum + (file.pages || 0), 0), + size: files.reduce((sum, file) => sum + (file.size || 0), 0) + }; +} + +function pick(text, pattern) { + const match = text.match(pattern); + return match ? match[1].trim() : ''; +} + +function sanitize(value) { + return String(value).trim().replace(/[\\/:*?"<>|]/g, '-'); +} + +function basename(filePath) { + const name = String(filePath).split(/[\\/]/).pop() || ''; + const dotIndex = name.lastIndexOf('.'); + return dotIndex > 0 ? name.slice(0, dotIndex) : name; +} diff --git a/plugins/pdf-office-workbench/tests/core.test.mjs b/plugins/pdf-office-workbench/tests/core.test.mjs new file mode 100644 index 00000000..909d367c --- /dev/null +++ b/plugins/pdf-office-workbench/tests/core.test.mjs @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + buildDocumentRenamePlan, + buildPdfMergePlan, + buildPdfSplitPlan, + extractInvoiceFields, + parsePageRanges, + summarizePdfBatch +} from '../src/core/index.mjs'; + +test('buildPdfMergePlan orders files and names output', () => { + const plan = buildPdfMergePlan([ + { path: 'b.pdf', pages: 2 }, + { path: 'a.pdf', pages: 3 } + ], { outputName: 'merged.pdf' }); + + assert.deepEqual(plan, { outputName: 'merged.pdf', totalPages: 5, files: ['a.pdf', 'b.pdf'] }); +}); + +test('buildPdfSplitPlan creates page range tasks', () => { + assert.deepEqual(buildPdfSplitPlan('report.pdf', '1-2,4,6-7'), [ + { source: 'report.pdf', range: [1, 2], targetName: 'report-p1-2.pdf' }, + { source: 'report.pdf', range: [4, 4], targetName: 'report-p4.pdf' }, + { source: 'report.pdf', range: [6, 7], targetName: 'report-p6-7.pdf' } + ]); +}); + +test('parsePageRanges rejects malformed and reversed ranges', () => { + assert.deepEqual(parsePageRanges('1-3, 5'), [[1, 3], [5, 5]]); + assert.throws(() => parsePageRanges('0-2'), /无效页码范围/); + assert.throws(() => parsePageRanges('4-2'), /无效页码范围/); + assert.throws(() => parsePageRanges(''), /至少一个页码范围/); +}); + +test('extractInvoiceFields reads common invoice text fields', () => { + const fields = extractInvoiceFields('发票号码: 12345678\n开票日期: 2026-06-01\n金额 ¥88.50\n购买方: ZTools'); + + assert.deepEqual(fields, { + invoiceNumber: '12345678', + date: '2026-06-01', + amount: '88.50', + buyer: 'ZTools' + }); +}); + +test('buildDocumentRenamePlan uses extracted fields with fallback index', () => { + const plan = buildDocumentRenamePlan([ + { path: '/tmp/a.pdf', fields: { date: '2026-06-01', buyer: 'ZTools', amount: '88.50' } }, + { path: '/tmp/b.pdf', fields: {} } + ], '{date}-{buyer}-{amount}'); + + assert.deepEqual(plan.map(item => item.targetName), ['2026-06-01-ZTools-88.50.pdf', '未命名-2.pdf']); +}); + +test('summarizePdfBatch totals documents and pages', () => { + assert.deepEqual(summarizePdfBatch([{ pages: 2, size: 1024 }, { pages: 3, size: 2048 }]), { + documents: 2, + pages: 5, + size: 3072 + }); +}); diff --git a/plugins/pdf-office-workbench/tests/file-operations.test.mjs b/plugins/pdf-office-workbench/tests/file-operations.test.mjs new file mode 100644 index 00000000..16f85103 --- /dev/null +++ b/plugins/pdf-office-workbench/tests/file-operations.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const { PDFDocument } = require('../preload/node_modules/pdf-lib'); + +async function makePdf(filePath, pageCount) { + const document = await PDFDocument.create(); + for (let index = 0; index < pageCount; index += 1) { + const page = document.addPage([240, 160]); + page.drawText(`page ${index + 1}`); + } + await fs.writeFile(filePath, await document.save()); +} + +test('preload services merge, split and rename real PDF files', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pdf-office-workbench-')); + const previousWindow = globalThis.window; + globalThis.window = { ztools: {} }; + try { + require('../preload/services.js'); + const serviceModule = globalThis.window.services; + const first = path.join(root, 'first.pdf'); + const second = path.join(root, 'second.pdf'); + await makePdf(first, 2); + await makePdf(second, 1); + + const firstInfo = await serviceModule.readPdfInfo(first); + assert.equal(firstInfo.pages, 2); + + const merged = await serviceModule.mergePdfs([first, second], path.join(root, 'merged.pdf')); + assert.equal(merged.pages, 3); + assert.equal((await serviceModule.readPdfInfo(merged.path)).pages, 3); + + const split = await serviceModule.splitPdf(merged.path, '1-2,3', root); + assert.deepEqual(split.map(item => item.pages), [2, 1]); + assert.equal((await serviceModule.readPdfInfo(split[0].path)).pages, 2); + + const renamed = await serviceModule.renameFiles([split[0].path, split[1].path], 'invoice-{index}'); + assert.deepEqual(renamed.map(item => item.name), ['invoice-1.pdf', 'invoice-2.pdf']); + assert.equal((await serviceModule.readPdfInfo(renamed[1].path)).pages, 1); + } finally { + globalThis.window = previousWindow; + await fs.rm(root, { recursive: true, force: true }); + } +});