diff --git a/package-lock.json b/package-lock.json index 61447b5e..99c523ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "browser-fs-access": "^0.29.5", + "filesize": "^10.1.0", "jspdf": "^2.5.1", "magica": "^0.2.17", "magica-re-export": "^1.0.9", @@ -4932,6 +4933,14 @@ "node": ">=10" } }, + "node_modules/filesize": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.0.tgz", + "integrity": "sha512-GTLKYyBSDz3nPhlLVPjPWZCnhkd9TrrRArNcy8Z+J2cqScB7h2McAzR6NBX6nYOoWafql0roY8hrocxnZBv9CQ==", + "engines": { + "node": ">= 10.4.0" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -11916,6 +11925,11 @@ } } }, + "filesize": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.0.tgz", + "integrity": "sha512-GTLKYyBSDz3nPhlLVPjPWZCnhkd9TrrRArNcy8Z+J2cqScB7h2McAzR6NBX6nYOoWafql0roY8hrocxnZBv9CQ==" + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", diff --git a/package.json b/package.json index 17012846..c5f79275 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "browser-fs-access": "^0.29.5", + "filesize": "^10.1.0", "jspdf": "^2.5.1", "magica": "^0.2.17", "magica-re-export": "^1.0.9", diff --git a/src/components/canvas-scan/canvas-scan-settings/ScanSettingsCard.vue b/src/components/canvas-scan/canvas-scan-settings/ScanSettingsCard.vue new file mode 100644 index 00000000..4add73d4 --- /dev/null +++ b/src/components/canvas-scan/canvas-scan-settings/ScanSettingsCard.vue @@ -0,0 +1,66 @@ + + + diff --git a/src/components/canvas-scan/canvas-scan-settings/settings/BlurSetting.vue b/src/components/canvas-scan/canvas-scan-settings/settings/BlurSetting.vue new file mode 100644 index 00000000..6103635f --- /dev/null +++ b/src/components/canvas-scan/canvas-scan-settings/settings/BlurSetting.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/components/canvas-scan/canvas-scan-settings/settings/BorderSetting.vue b/src/components/canvas-scan/canvas-scan-settings/settings/BorderSetting.vue new file mode 100644 index 00000000..8e999b36 --- /dev/null +++ b/src/components/canvas-scan/canvas-scan-settings/settings/BorderSetting.vue @@ -0,0 +1,40 @@ + + + diff --git a/src/components/canvas-scan/canvas-scan-settings/settings/BrightnessSetting.vue b/src/components/canvas-scan/canvas-scan-settings/settings/BrightnessSetting.vue new file mode 100644 index 00000000..559ccdc6 --- /dev/null +++ b/src/components/canvas-scan/canvas-scan-settings/settings/BrightnessSetting.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/components/canvas-scan/canvas-scan-settings/settings/ColorspaceSetting.vue b/src/components/canvas-scan/canvas-scan-settings/settings/ColorspaceSetting.vue new file mode 100644 index 00000000..5dfe4db6 --- /dev/null +++ b/src/components/canvas-scan/canvas-scan-settings/settings/ColorspaceSetting.vue @@ -0,0 +1,65 @@ + + + diff --git a/src/components/canvas-scan/canvas-scan-settings/settings/ContrastSetting.vue b/src/components/canvas-scan/canvas-scan-settings/settings/ContrastSetting.vue new file mode 100644 index 00000000..91734928 --- /dev/null +++ b/src/components/canvas-scan/canvas-scan-settings/settings/ContrastSetting.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/components/canvas-scan/canvas-scan-settings/settings/NoiseSetting.vue b/src/components/canvas-scan/canvas-scan-settings/settings/NoiseSetting.vue new file mode 100644 index 00000000..b72f025c --- /dev/null +++ b/src/components/canvas-scan/canvas-scan-settings/settings/NoiseSetting.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/src/components/canvas-scan/canvas-scan-settings/settings/RotateSetting.vue b/src/components/canvas-scan/canvas-scan-settings/settings/RotateSetting.vue new file mode 100644 index 00000000..3c40e5ed --- /dev/null +++ b/src/components/canvas-scan/canvas-scan-settings/settings/RotateSetting.vue @@ -0,0 +1,44 @@ + + + diff --git a/src/components/canvas-scan/canvas-scan-settings/settings/RotateVarianceSetting.vue b/src/components/canvas-scan/canvas-scan-settings/settings/RotateVarianceSetting.vue new file mode 100644 index 00000000..4fa548ca --- /dev/null +++ b/src/components/canvas-scan/canvas-scan-settings/settings/RotateVarianceSetting.vue @@ -0,0 +1,62 @@ + + + diff --git a/src/components/canvas-scan/canvas-scan-settings/settings/ScaleSetting.vue b/src/components/canvas-scan/canvas-scan-settings/settings/ScaleSetting.vue new file mode 100644 index 00000000..c2a9cfbf --- /dev/null +++ b/src/components/canvas-scan/canvas-scan-settings/settings/ScaleSetting.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/components/canvas-scan/canvas-scan-settings/settings/dark-noise.svg b/src/components/canvas-scan/canvas-scan-settings/settings/dark-noise.svg new file mode 100644 index 00000000..1336662b --- /dev/null +++ b/src/components/canvas-scan/canvas-scan-settings/settings/dark-noise.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/canvas-scan/canvas-scan-settings/settings/noise.svg b/src/components/canvas-scan/canvas-scan-settings/settings/noise.svg new file mode 100644 index 00000000..b27e76c1 --- /dev/null +++ b/src/components/canvas-scan/canvas-scan-settings/settings/noise.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/canvas-scan/preview/ImagePreview.vue b/src/components/canvas-scan/preview/ImagePreview.vue new file mode 100644 index 00000000..65180da3 --- /dev/null +++ b/src/components/canvas-scan/preview/ImagePreview.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/src/components/canvas-scan/preview/PreviewCompare.vue b/src/components/canvas-scan/preview/PreviewCompare.vue new file mode 100644 index 00000000..f3436bb2 --- /dev/null +++ b/src/components/canvas-scan/preview/PreviewCompare.vue @@ -0,0 +1,104 @@ + + + diff --git a/src/components/canvas-scan/preview/PreviewPagination.vue b/src/components/canvas-scan/preview/PreviewPagination.vue new file mode 100644 index 00000000..be12c062 --- /dev/null +++ b/src/components/canvas-scan/preview/PreviewPagination.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/canvas-scan/preview/SideBySidePreview.vue b/src/components/canvas-scan/preview/SideBySidePreview.vue new file mode 100644 index 00000000..864675e9 --- /dev/null +++ b/src/components/canvas-scan/preview/SideBySidePreview.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/pdf-upload/PDFInfo.vue b/src/components/pdf-upload/PDFInfo.vue new file mode 100644 index 00000000..bfe91853 --- /dev/null +++ b/src/components/pdf-upload/PDFInfo.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/src/components/pdf-upload/PDFUpload.vue b/src/components/pdf-upload/PDFUpload.vue new file mode 100644 index 00000000..6bc4e1d3 --- /dev/null +++ b/src/components/pdf-upload/PDFUpload.vue @@ -0,0 +1,37 @@ + + + diff --git a/src/components/save-button/SaveButtonCard.vue b/src/components/save-button/SaveButtonCard.vue new file mode 100644 index 00000000..6159bf3d --- /dev/null +++ b/src/components/save-button/SaveButtonCard.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/src/composables/save-scanned-pdf/index.ts b/src/composables/save-scanned-pdf/index.ts new file mode 100644 index 00000000..4fd1e1f5 --- /dev/null +++ b/src/composables/save-scanned-pdf/index.ts @@ -0,0 +1,85 @@ +import type { Ref } from "vue"; +import { get } from "@vueuse/core"; +import { ref, computed } from "vue"; + +interface PDFRenderer { + renderPage( + page: number, + scale: number + ): Promise<{ + blob: Blob; + }>; + getNumPages(): Promise; +} + +interface ScanRenderer { + renderPage(image: Blob): Promise<{ + blob: Blob; + height: number; + width: number; + }>; +} + +export function useSaveScannedPDF( + pdfRenderer: PDFRenderer | undefined | Ref, + scanRenderer: ScanRenderer | undefined | Ref, + scale: Ref | number +) { + const finishedPages = ref(0); + const totalPages = ref(0); + const progress = computed(() => { + if (totalPages.value === 0) { + return 0; + } + return finishedPages.value / totalPages.value; + }); + + const saving = ref(false); + + const save = async () => { + try { + finishedPages.value = 0; + totalPages.value = 0; + saving.value = true; + + const pdf = get(pdfRenderer); + const scan = get(scanRenderer); + const scale_ = get(scale); + + if (!pdf || !scan) { + throw new Error("No PDF or Scan Renderer"); + } + + const numPages = await pdf.getNumPages(); + + totalPages.value = numPages; + + // generate pdf pages 1...n + const pages = Array.from({ length: numPages }, (_, i) => i + 1); + const scanPages = await Promise.all( + pages.map(async (page) => { + const pdfPage = (await pdf.renderPage(page, scale_)).blob; + const scanPage = await scan.renderPage(pdfPage); + finishedPages.value += 1; + return { + ...scanPage, + dpi: scale_ * 72, + }; + }) + ); + + // generate pdf from scan pages + const { imagesToPDF } = await import("@/utils/images-to-pdf"); + const pdfDocument = await imagesToPDF(scanPages); + + return pdfDocument; + } catch (e) { + console.error(e); + throw e; + } finally { + saving.value = false; + } + }; + + return { save, progress, saving }; +} diff --git a/src/locale/en/settings.ts b/src/locale/en/settings.ts index d70d6d2a..c42dcf93 100644 --- a/src/locale/en/settings.ts +++ b/src/locale/en/settings.ts @@ -1,6 +1,7 @@ export const settings = { settings: "Customization", attenuate: "Noise", + noise: "Noise", blur: "Blur", border: { label: "Border", @@ -17,4 +18,6 @@ export const settings = { pdfSelectLabel: "Select PDF", pdfNoSelectMessage: "No PDF selected", scale: "Resolution", + brightness: "Brightness", + contrast: "Contrast", }; diff --git a/src/locale/zh-CN/settings.ts b/src/locale/zh-CN/settings.ts index 5eb27664..7c820960 100644 --- a/src/locale/zh-CN/settings.ts +++ b/src/locale/zh-CN/settings.ts @@ -1,6 +1,7 @@ export const settings = { settings: "扫描设置", attenuate: "噪点", + noise: "噪点", blur: "模糊", border: { label: "边框", @@ -17,4 +18,6 @@ export const settings = { pdfSelectLabel: "选择 PDF 文件", pdfNoSelectMessage: "尚未选择 PDF 文件", scale: "分辨率", + brightness: "亮度", + contrast: "对比度", }; diff --git a/src/router/index.ts b/src/router/index.ts index cb03cf86..6db4956b 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -12,6 +12,16 @@ const router = createRouter({ { path: "/scan", name: "scan", + component: () => import("@/views/CanvasScanView.vue"), + }, + { + path: "/scan-canvas", + name: "scan-canvas", + component: () => import("@/views/CanvasScanView.vue"), + }, + { + path: "/scan-magica", + name: "scan-magica", component: () => import("@/views/ScanView.vue"), }, // catch all redirect to / diff --git a/src/utils/canvas-scan/index.ts b/src/utils/canvas-scan/index.ts new file mode 100644 index 00000000..c90e612c --- /dev/null +++ b/src/utils/canvas-scan/index.ts @@ -0,0 +1,2 @@ +export { type ScanConfig, defaultConfig, colorspaces } from "./types"; +export { CanvasScanner } from "./scanner"; diff --git a/src/utils/canvas-scan/noise-svg.ts b/src/utils/canvas-scan/noise-svg.ts new file mode 100644 index 00000000..efdfa535 --- /dev/null +++ b/src/utils/canvas-scan/noise-svg.ts @@ -0,0 +1,24 @@ +// https://fffuel.co/nnnoise/ + +export function getNoiseSVG(noise: number): string { + return ` + + + + + + + + + + + +`; +} diff --git a/src/utils/canvas-scan/scan-canvas.ts b/src/utils/canvas-scan/scan-canvas.ts new file mode 100644 index 00000000..3c36e44d --- /dev/null +++ b/src/utils/canvas-scan/scan-canvas.ts @@ -0,0 +1,79 @@ +import type { ScanConfig } from "./types"; +import { getNoiseSVG } from "./noise-svg"; + +export async function scanCanvas( + canvas_: HTMLCanvasElement | OffscreenCanvas, + page: Blob, + config: ScanConfig, + signal?: AbortSignal +): Promise { + if (signal?.aborted) { + throw new Error("Aborted"); + } + + // Note: Hack to get around TS error + const canvas = canvas_ as HTMLCanvasElement; + const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; + if (!ctx) { + throw new Error("Canvas not supported"); + } + + // load blob into image + const img = await createImageBitmap(page); + if (signal?.aborted) { + throw new Error("Aborted"); + } + + const width = img.width; + const height = img.height; + + canvas.width = width; + canvas.height = height; + + // fill white + ctx.fillStyle = "white"; + ctx.fillRect(0, 0, width, height); + + // add blur + ctx.filter = `blur(${config.blur}px)`; + if (config.colorspace === "gray") { + ctx.filter += " grayscale(1)"; + } + + // add brightness + ctx.filter += ` brightness(${config.brightness})`; + + // add contrast + ctx.filter += ` contrast(${config.contrast})`; + + // rotate + ctx.translate(width / 2, height / 2); + ctx.rotate( + ((config.rotate + config.rotate_var * Math.random()) * Math.PI) / 180 + ); + ctx.translate(-width / 2, -height / 2); + + ctx.drawImage(img, 0, 0); + + if (config.noise !== 0) { + const noiseSVG = getNoiseSVG(config.noise); + const noiseSVGBlob = new Blob([noiseSVG], { type: "image/svg+xml" }); + const noiseSVGURL = URL.createObjectURL(noiseSVGBlob); + + const noiseImg = new Image(); + noiseImg.src = noiseSVGURL; + await new Promise((resolve) => (noiseImg.onload = resolve)); + if (signal?.aborted) { + throw new Error("Aborted"); + } + + // add noise + ctx.drawImage(noiseImg, -width, -height, width * 2, height * 2); + } + + if (config.border) { + ctx.strokeStyle = "black"; + ctx.lineWidth = 1; + ctx.strokeRect(0, 0, width, height); + } +} diff --git a/src/utils/canvas-scan/scanner.ts b/src/utils/canvas-scan/scanner.ts new file mode 100644 index 00000000..2327b475 --- /dev/null +++ b/src/utils/canvas-scan/scanner.ts @@ -0,0 +1,72 @@ +import { scanCanvas } from "./scan-canvas"; +import type { ScanConfig } from "./types"; + +interface ScanRenderer { + renderPage( + image: Blob, + options?: { + signal?: AbortSignal; + } + ): Promise<{ + blob: Blob; + height: number; + width: number; + }>; +} + +export class CanvasScanner implements ScanRenderer { + config: ScanConfig; + + constructor(config: ScanConfig) { + this.config = config; + } + + async renderPage( + image: Blob, + options?: { + signal?: AbortSignal; + } + ): Promise<{ + blob: Blob; + height: number; + width: number; + }> { + if (options?.signal?.aborted) { + throw new Error("Aborted"); + } + + if ("OffscreenCanvas" in window) { + // TODO: use web worker + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const canvas = new OffscreenCanvas(10, 10); + await scanCanvas(canvas, image, this.config); + const blob = await canvas.convertToBlob({ + type: this.config.output_format, + }); + const height = canvas.height; + const width = canvas.width; + return { blob, height, width }; + } else { + const canvas = document.createElement("canvas"); + await scanCanvas(canvas, image, this.config); + if (options?.signal?.aborted) { + throw new Error("Aborted"); + } + + const blob = await new Promise((resolve, reject) => + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error("Canvas to Blob failed")); + } + }, this.config.output_format) + ); + const height = canvas.height; + const width = canvas.width; + canvas.remove(); + return { blob, height, width }; + } + } +} diff --git a/src/utils/canvas-scan/types.ts b/src/utils/canvas-scan/types.ts new file mode 100644 index 00000000..de00b1af --- /dev/null +++ b/src/utils/canvas-scan/types.ts @@ -0,0 +1,27 @@ +export const colorspaces = ["gray", "sRGB"] as const; + +export interface ScanConfig { + rotate: number; + rotate_var: number; + colorspace: typeof colorspaces[number]; + blur: number; + noise: number; + border: boolean; + scale: number; + brightness: number; + contrast: number; + output_format: "image/png" | "image/jpeg"; +} + +export const defaultConfig: ScanConfig = { + rotate: 1, + rotate_var: 0.5, + colorspace: "gray", + blur: 0.3, + noise: 0.1, + border: false, + scale: 2, + brightness: 1, + contrast: 1, + output_format: "image/jpeg", +}; diff --git a/src/utils/pdf-new/PDF.ts b/src/utils/pdf-new/PDF.ts new file mode 100644 index 00000000..b9ae07f6 --- /dev/null +++ b/src/utils/pdf-new/PDF.ts @@ -0,0 +1,122 @@ +import type { PDFDocumentProxy } from "pdfjs-dist/types/src/pdf"; +import { standardFontDataFactory } from "./standardFontDataFactory"; + +export interface PDFPageInfo { + blob: Blob; + page: number; + height: number; + width: number; + scale: number; + dpi: number; +} + +export interface PDFInfoType { + source: string; + filename: string; +} + +export interface PDFRenderer { + renderPage(page: number, scale: number): Promise; + getNumPages(): Promise; +} + +export class PDF implements PDFRenderer { + private readonly pdf: File; + + private pdfDocument?: PDFDocumentProxy; + private readonly initPromise: Promise; + private readonly pagePromises: Map> = new Map(); + + constructor(pdf: File) { + this.pdf = pdf; + this.initPromise = this.init(); + } + + async init() { + const { getDocument } = await import("./getDocument"); + const url = URL.createObjectURL(this.pdf); + const pdfDocument = await getDocument({ + url: url, + StandardFontDataFactory: standardFontDataFactory, + }).promise; + this.pdfDocument = pdfDocument; + } + + private async getDocument(): Promise { + await this.initPromise; + if (!this.pdfDocument) { + throw new Error("PDF document is not initialized"); + } + return this.pdfDocument; + } + + async getNumPages(): Promise { + await this.initPromise; + if (!this.pdfDocument) { + throw new Error("PDF document is not initialized"); + } + + return this.pdfDocument.numPages; + } + + async renderPage(page: number, scale: number): Promise { + const promise = this.pagePromises.get(`${page}-${scale}`); + if (promise) { + return await promise; + } + + const pageInfoPromise = this.renderPageRaw(page, scale); + this.pagePromises.set(`${page}-${scale}`, pageInfoPromise); + + return await pageInfoPromise; + } + + private async renderPageRaw( + page: number, + scale: number + ): Promise { + await this.initPromise; + + const dpi = scale * 72; + const pdfDocument = await this.getDocument(); + const pdfPage = await pdfDocument.getPage(page); + const viewport = pdfPage.getViewport({ scale }); + const width = viewport.width; + const height = viewport.height; + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + const renderTask = pdfPage.render({ + canvasContext: ctx as object, + viewport, + }); + await renderTask.promise; + + const blob: Blob = await new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error("Canvas to Blob failed")); + } + }); + }); + + canvas.remove(); + + const pageInfo = { + blob, + page, + height, + width, + scale, + dpi, + }; + + pdfPage.cleanup(); + + return pageInfo; + } +} diff --git a/src/utils/pdf-new/getDocument.ts b/src/utils/pdf-new/getDocument.ts new file mode 100644 index 00000000..b020d26f --- /dev/null +++ b/src/utils/pdf-new/getDocument.ts @@ -0,0 +1,5 @@ +import pdfJsWorkerURL from "pdfjs-dist/build/pdf.worker.min.js?url"; +import { GlobalWorkerOptions, getDocument } from "pdfjs-dist"; +GlobalWorkerOptions.workerSrc = pdfJsWorkerURL; + +export { getDocument }; diff --git a/src/utils/pdf-new/index.ts b/src/utils/pdf-new/index.ts new file mode 100644 index 00000000..36f41de7 --- /dev/null +++ b/src/utils/pdf-new/index.ts @@ -0,0 +1,2 @@ +export { PDF } from "./PDF"; +export type { PDFInfoType } from "./PDF"; diff --git a/src/utils/pdf-new/standardFontDataFactory.ts b/src/utils/pdf-new/standardFontDataFactory.ts new file mode 100644 index 00000000..7ca3423e --- /dev/null +++ b/src/utils/pdf-new/standardFontDataFactory.ts @@ -0,0 +1,38 @@ +export interface StandardFontDataFactoryType { + fetch: (req: { filename: string }) => Promise; +} + +export class standardFontDataFactory { + async fetch(req: { filename: string }): Promise { + const { filename } = req; + const url = getFontURL(filename); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch font: ${url}`); + } + + return new Uint8Array(await response.arrayBuffer()); + } +} + +function getFontURL(filename: string) { + if (filename.endsWith(".pfb")) { + const base = filename.slice(0, -4); + + return new URL( + `../../../node_modules/pdfjs-dist/standard_fonts/${base}.pfb`, + import.meta.url + ).href; + } + + if (filename.endsWith(".ttf")) { + const base = filename.slice(0, -4); + + return new URL( + `../../../node_modules/pdfjs-dist/standard_fonts/${base}.ttf`, + import.meta.url + ).href; + } + + throw new Error(`Unknown font filename: ${filename}`); +} diff --git a/src/utils/pdf/draw-on-canvas.ts b/src/utils/pdf/draw-on-canvas.ts new file mode 100644 index 00000000..711fa9da --- /dev/null +++ b/src/utils/pdf/draw-on-canvas.ts @@ -0,0 +1,32 @@ +import type { PDFDocumentProxy } from "pdfjs-dist/types/src/pdf"; + +export async function drawOnCanvas( + canvas: HTMLCanvasElement, + pdfDocument: PDFDocumentProxy, + page: number, + scale: number, + options?: { + signal?: AbortSignal; + } +): Promise { + console.log(pdfDocument); + const pdfPage = await pdfDocument.getPage(page); + + if (options?.signal?.aborted) { + throw new DOMException("Aborted", "AbortError"); + } + + const viewport = pdfPage.getViewport({ scale }); + const width = viewport.width; + const height = viewport.height; + + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + + const renderTask = pdfPage.render({ + canvasContext: ctx as object, + viewport, + }); + await renderTask.promise; +} diff --git a/src/views/CanvasScanView.vue b/src/views/CanvasScanView.vue new file mode 100644 index 00000000..d59708b4 --- /dev/null +++ b/src/views/CanvasScanView.vue @@ -0,0 +1,106 @@ + + +